Flutter Clean Architecture Weather App

Build a production-grade Flutter weather app with BLoC, GoRouter, and get_it.

Introduction

30 Second Summary

Every app starts simple, but as features pile up, code without clear boundaries turns into a tangled mess where fixing one bug creates three more. Enterprise teams solve this with layered architecture that keeps each part of the app independent and testable.

In this project, you will build a Flutter weather dashboard using Clean Architecture, wiring live API data through distinct domain, data, and presentation layers connected by dependency injection.

What You'll Build

Show a recruiter your running app, tap through the bottom navigation, explain why the domain layer has zero framework dependencies, and demonstrate how network errors flow gracefully to the UI as a styled error card instead of a crash.

By the end of this project, you'll have:

  • A live weather dashboard showing real temperature, conditions, and wind data for a city, proving the full vertical slice from HTTP client to UI widget works end-to-end.
  • A route guard that blocks the Settings tab until you simulate a login, demonstrating GoRouter's onEnter mechanism for protecting routes at the router level.
  • Structured error handling where toggling airplane mode renders a clean error card with a retry button instead of a crash.
  • Secret Mission: Add a 5-day forecast as a second vertical slice, proving the architecture scales without modifying existing features.

Are there any prerequisites?

This project assumes you have Flutter 3.44 and Dart 3.12 installed, along with Cursor IDE. You should be comfortable with basic Dart syntax and Flutter widget composition.

You will sign up for a free OpenWeatherMap account during the project. No billing information or paid subscriptions are required.

Before We Start

Before we dive in, let's lock in what you're about to build and why the architecture decisions matter.

Set Up the Project and Dependencies

Every Clean Architecture project starts with a strict foundation: the right dependencies locked to specific versions and a folder structure that enforces layer boundaries from day one.

In this step, you will scaffold a new Flutter project, install the six packages that power the architecture (state management, routing, dependency injection, networking), and create the feature-first folder structure that separates data, domain, and presentation layers.

In this step, get ready to:
  • Create the Flutter project and install all dependencies.
  • Build the feature-first folder structure.
  • Verify the app runs in Chrome.
Create the Flutter project and install dependencies

You need a fresh Flutter project on your Desktop with the minimal template. The --empty flag skips the default counter app boilerplate and gives you a clean starting point.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Cursor and press Enter to open it.
  • Open a terminal in Cursor by pressing Ctrl+` (backtick key) or clicking Terminal in the top menu bar, then New Terminal.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the Flutter project by running this command:
flutter create weather_engine --empty

You should see Flutter generate the project files, ending with a message listing the created directory.

  • Move into the new project folder by running:
cd weather_engine

Seeing 'directory already exists'?

If a folder named weather_engine already exists on your Desktop, delete it first with rm -rf ~/Desktop/weather_engine (macOS/Linux) or remove the folder in File Explorer (Windows), then run the create command again.

Help me troubleshoot this error

  • Open the project folder in Cursor. Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the weather_engine folder, and click Open.

Now you need to declare the packages the project depends on. The pubspec.yaml file is where Flutter manages dependencies, similar to package.json in Node projects.

  • In the Cursor sidebar, click on pubspec.yaml to open it.
  • Select all the existing content (Cmd+A on macOS, Ctrl+A on Windows) and replace it with the following:
name: weather_engine
description: A production-grade Flutter weather app with Clean Architecture.
publish_to: 'none'

environment:
  sdk: ^3.12.0
  flutter: ^3.44.0

dependencies:
  flutter:
    sdk: flutter
  flutter_bloc: ^9.1.1
  bloc: ^9.2.1
  go_router: ^17.3.0
  get_it: ^9.2.1
  equatable: ^2.1.0
  http: ^1.6.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

What do these dependencies do?

  • flutter_bloc and bloc: the BLoC state management pattern. Events go in, states come out.
  • go_router: declarative routing with route guards and shell navigation.
  • get_it: a service locator for dependency injection. It wires all layers together without needing BuildContext.
  • equatable: makes Dart classes comparable by value (needed for BLoC state equality checks).
  • http: a lightweight HTTP client for calling the weather API.
  • Save pubspec.yaml.
  • Open a new terminal in Cursor (Ctrl+`) if your previous one closed when reopening the folder.
  • Install the dependencies by running this command:
flutter pub get

You should see output ending with a confirmation that dependencies resolved successfully.

Seeing version conflicts?

Make sure you replaced the entire pubspec.yaml file content, not just appended to it. Leftover lines from the original file can cause version conflicts.

Also confirm your terminal is inside the weather_engine folder. Run pwd to check your current directory.

Help me fix version conflicts

✔️ Awesome, I've got everything!

Great. Double check you have saved pubspec.yaml and the terminal shows no errors.

ⓧ I'd like to double check the full code

Your pubspec.yaml should look exactly like this:

name: weather_engine
description: A production-grade Flutter weather app with Clean Architecture.
publish_to: 'none'

environment:
  sdk: ^3.12.0
  flutter: ^3.44.0

dependencies:
  flutter:
    sdk: flutter
  flutter_bloc: ^9.1.1
  bloc: ^9.2.1
  go_router: ^17.3.0
  get_it: ^9.2.1
  equatable: ^2.1.0
  http: ^1.6.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0
Create the feature-first folder structure

A feature-first folder structure groups code by what it does (weather feature, auth feature) rather than by type (all models in one folder, all pages in another). Each feature contains its own data, domain, and presentation layers.

  • Create all the project directories by running these commands in your terminal:
mkdir -p lib/core/error lib/core/network
mkdir -p lib/features/weather/data/models lib/features/weather/data/datasources lib/features/weather/data/repositories
mkdir -p lib/features/weather/domain/entities lib/features/weather/domain/usecases
mkdir -p lib/features/weather/presentation/bloc lib/features/weather/presentation/pages lib/features/weather/presentation/widgets
mkdir -p lib/app/router lib/app/di

What does this structure represent?

  • core/: shared utilities like error types and network constants.
  • features/weather/domain/: entities and use cases. This layer has zero dependencies on Flutter or external packages.
  • features/weather/data/: models, data sources, and repository implementations. This layer talks to the outside world.
  • features/weather/presentation/: BLoC, pages, and widgets. This layer renders state to the screen.
  • app/: top-level wiring. The router and dependency injection container live here.
  • Confirm the folders were created by running:
find lib -type d | sort

You should see all the directories listed in alphabetical order, including lib/app/di, lib/core/error, lib/features/weather/presentation/widgets, and all the others.

Missing directories?

Make sure your terminal is inside the weather_engine folder (not inside lib/ or one level above). Run pwd and confirm it ends with weather_engine.

Help me fix my missing folders

Verify the project runs in Chrome

The --empty template generates a minimal Flutter app with a MaterialApp, Scaffold, and a centered "Hello World!" text. Running it confirms your environment, dependencies, and project structure are all wired correctly.

Before you run this, what do you expect to see in Chrome? A full weather dashboard, or something simpler?

  • Launch the app in Chrome by running:
flutter run -d chrome

A Chrome window opens with a minimal Flutter app. You should see "Hello World!" centered on a white screen with a debug banner in the top-right corner.

Why does it look so plain?

The --empty template deliberately strips out the default counter demo. You are looking at the minimal runnable Flutter app. Over the next steps you will replace this with a full weather dashboard powered by Clean Architecture.

Chrome not opening?

Make sure Chrome is installed on your machine. Flutter web requires Chrome (or a Chromium-based browser) as the target device.

If you see "No supported devices connected", run flutter devices to list available devices. You should see Chrome listed. If not, run flutter doctor and follow the suggestions for web setup.

Help me debug my Chrome issue

Your project is scaffolded, dependencies are locked, and the folder structure is ready. Next up, you will build the domain and data layers that define what your app does and how it fetches weather data.

Build the Domain and Data Layers

Your project has the folder skeleton, but every folder is still empty. The domain layer defines what the app does (entities, use cases, failure types) without depending on any framework or package.

The data layer implements how it gets done. Building domain-first forces you to think contracts-first, which is the entire point of Clean Architecture. By the end of this step, you will have a full vertical slice from entity to HTTP call.

In this step, get ready to:
  • Build the domain layer with zero external dependencies.
  • Build the data layer that implements domain contracts.
  • Wire up your OpenWeatherMap API key and verify a live API call.
Build the domain layer (zero dependencies)

The domain layer contains pure Dart classes with no Flutter imports, no HTTP packages, and no third-party code. It defines the contracts that every other layer must follow.

  • Create a new file at lib/core/error/failures.dart and add the following code:
import 'package:equatable/equatable.dart';

// Sealed class ensures all failure types are known at compile time
sealed class Failure extends Equatable {
  final String message;
  const Failure(this.message);

  @override
  List<Object> get props => [message];
}

class ServerFailure extends Failure {
  const ServerFailure(super.message);
}

class NetworkFailure extends Failure {
  const NetworkFailure(super.message);
}

What does this code do?

The sealed keyword means Dart knows every possible subclass at compile time. When you pattern-match on a Failure, the compiler guarantees you handle both ServerFailure and NetworkFailure.

Extending Equatable lets you compare failure objects by value (their message field) instead of by reference.

  • Save failures.dart.
  • Create a new file at lib/core/error/exceptions.dart and add this code:
// Raw exceptions thrown by data sources, caught by repositories
class ServerException implements Exception {
  final String message;
  const ServerException(this.message);
}

class NetworkException implements Exception {
  final String message;
  const NetworkException(this.message);
}

Exceptions vs Failures?

Exceptions are thrown inside the data layer when something goes wrong (a bad HTTP status, no internet). The repository catches them and converts them into typed Failure objects that the domain layer understands.

This separation means the domain never imports dart:io or knows about HTTP. It only sees clean, testable failure types.

  • Save exceptions.dart, then verify both error files compile by running this in your terminal:
dart analyze lib/core/error/

You should see No issues found! confirming both files are valid Dart.

Seeing analysis errors?

Check that equatable is listed in your pubspec.yaml dependencies and that you ran flutter pub get in the previous step.

Also verify the import path is exactly package:equatable/equatable.dart with no typos.

help me fix dart analyze errors in my failures.dart file

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on.

ⓧ I'd like to double check the full code

Here is lib/core/error/failures.dart:

import 'package:equatable/equatable.dart';

sealed class Failure extends Equatable {
  final String message;
  const Failure(this.message);

  @override
  List<Object> get props => [message];
}

class ServerFailure extends Failure {
  const ServerFailure(super.message);
}

class NetworkFailure extends Failure {
  const NetworkFailure(super.message);
}

Here is lib/core/error/exceptions.dart:

class ServerException implements Exception {
  final String message;
  const ServerException(this.message);
}

class NetworkException implements Exception {
  final String message;
  const NetworkException(this.message);
}
  • Create a new file at lib/features/weather/domain/entities/weather_entity.dart and add this code:
import 'package:equatable/equatable.dart';

// The core data structure the rest of the app works with
class WeatherEntity extends Equatable {
  final String cityName;
  final double temperature;
  final String condition;
  final String icon;
  final double windSpeed;
  final int humidity;

  const WeatherEntity({
    required this.cityName,
    required this.temperature,
    required this.condition,
    required this.icon,
    required this.windSpeed,
    required this.humidity,
  });

  @override
  List<Object> get props => [cityName, temperature, condition, icon, windSpeed, humidity];
}

What is an Entity in Clean Architecture?

An entity is the purest representation of your business data. WeatherEntity knows nothing about JSON, HTTP, or Flutter widgets. It just holds weather data with value equality.

Every other layer (data, presentation) depends on this entity. The entity depends on nothing except Equatable for value comparison.

  • Save weather_entity.dart.
  • Create a new file at lib/features/weather/domain/repositories/weather_repository.dart and add this code:
import '../../../../core/error/failures.dart';
import '../entities/weather_entity.dart';

// Contract the data layer must fulfill
abstract class WeatherRepository {
  Future<({WeatherEntity? data, Failure? failure})> getCurrentWeather(String cityName);
}

Why a record type instead of Either?

Many Clean Architecture tutorials use a functional programming package for an Either<Failure, Entity> type. Dart 3 records give you the same pattern with zero extra dependencies.

The record ({WeatherEntity? data, Failure? failure}) returns either the data or the failure. The caller checks which field is non-null.

  • Save weather_repository.dart, then verify the domain files so far by running:
dart analyze lib/features/weather/domain/

You should see No issues found! again.

Seeing unresolved import errors?

Double-check that your folder structure matches exactly. The import path ../../../../core/error/failures.dart requires weather_repository.dart to be four directories deep inside lib/.

help me fix unresolved import paths in my domain layer

  • Create a new file at lib/features/weather/domain/usecases/get_current_weather.dart and add this code:
import '../../../../core/error/failures.dart';
import '../entities/weather_entity.dart';
import '../repositories/weather_repository.dart';

// Each use case represents one thing the app can do
class GetCurrentWeather {
  final WeatherRepository repository;

  const GetCurrentWeather({required this.repository});

  Future<({WeatherEntity? data, Failure? failure})> call(String cityName) {
    return repository.getCurrentWeather(cityName);
  }
}

What is a UseCase?

A UseCase encapsulates a single action the app can perform. GetCurrentWeather takes a city name and returns weather data or a failure.

The call() method makes the use case callable like a function. The presentation layer calls it without knowing whether the data comes from an API, a cache, or a database.

  • Save get_current_weather.dart.

✔️ Awesome, I've got everything!

Great. Your entire domain layer is done. It has zero Flutter imports and zero third-party dependencies beyond Equatable.

ⓧ I'd like to double check the full code

Here is lib/features/weather/domain/entities/weather_entity.dart:

import 'package:equatable/equatable.dart';

class WeatherEntity extends Equatable {
  final String cityName;
  final double temperature;
  final String condition;
  final String icon;
  final double windSpeed;
  final int humidity;

  const WeatherEntity({
    required this.cityName,
    required this.temperature,
    required this.condition,
    required this.icon,
    required this.windSpeed,
    required this.humidity,
  });

  @override
  List<Object> get props => [cityName, temperature, condition, icon, windSpeed, humidity];
}

Here is lib/features/weather/domain/repositories/weather_repository.dart:

import '../../../../core/error/failures.dart';
import '../entities/weather_entity.dart';

abstract class WeatherRepository {
  Future<({WeatherEntity? data, Failure? failure})> getCurrentWeather(String cityName);
}

Here is lib/features/weather/domain/usecases/get_current_weather.dart:

import '../../../../core/error/failures.dart';
import '../entities/weather_entity.dart';
import '../repositories/weather_repository.dart';

class GetCurrentWeather {
  final WeatherRepository repository;

  const GetCurrentWeather({required this.repository});

  Future<({WeatherEntity? data, Failure? failure})> call(String cityName) {
    return repository.getCurrentWeather(cityName);
  }
}

What is the Repository pattern?

The abstract WeatherRepository is a contract. The domain layer says "I need a way to get weather data" without specifying how. The data layer provides the implementation.

This inversion of dependencies is the core rule of Clean Architecture. Inner layers define interfaces. Outer layers implement them.

Build the data layer (implements domain contracts)

The data layer is the "outer ring" that does the dirty work. It makes HTTP calls, parses JSON, and catches raw exceptions. It implements the abstract contracts your domain defined.

  • Create a new file at lib/core/network/api_constants.dart and add this code:
// Central place for API configuration
class ApiConstants {
  static const String apiKey = 'your-api-key-here';
  static const String baseUrl = 'https://api.openweathermap.org/data/2.5';
  static const String currentWeatherPath = '/weather';
  static const String forecastPath = '/forecast';
}

Why a placeholder key?

You will replace 'your-api-key-here' with your real API key in the next substep. Creating this file now lets the datasource import it without compile errors.

  • Save api_constants.dart.
  • Create a new file at lib/features/weather/data/models/weather_model.dart and add this code:
import '../../domain/entities/weather_entity.dart';

// Maps raw JSON from OpenWeatherMap into our domain entity
class WeatherModel extends WeatherEntity {
  const WeatherModel({
    required super.cityName,
    required super.temperature,
    required super.condition,
    required super.icon,
    required super.windSpeed,
    required super.humidity,
  });

  factory WeatherModel.fromJson(Map<String, dynamic> json) {
    return WeatherModel(
      cityName: json['name'] as String,
      temperature: (json['main']['temp'] as num).toDouble(),
      condition: json['weather'][0]['description'] as String,
      icon: json['weather'][0]['icon'] as String,
      windSpeed: (json['wind']['speed'] as num).toDouble(),
      humidity: json['main']['humidity'] as int,
    );
  }
}

What is a DTO-to-Entity mapping?

WeatherModel is a Data Transfer Object (DTO). It knows how to parse JSON fields like json['main']['temp'] into typed Dart values.

Because WeatherModel extends WeatherEntity, it IS a WeatherEntity. The domain layer never sees the model class directly. It only works with the entity.

  • Save weather_model.dart, then verify everything compiles so far:
dart analyze lib/features/weather/data/models/

You should see No issues found! confirming the model correctly extends the entity.

Getting a type error on super parameters?

Super parameters (required super.cityName) require Dart 3.0+. Run dart --version to confirm you are on 3.12 or higher.

help me debug super parameter errors in my WeatherModel

  • Create a new file at lib/features/weather/data/datasources/weather_remote_datasource.dart and start with the imports and interface definition:
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../../../../core/error/exceptions.dart';
import '../../../../core/network/api_constants.dart';
import '../models/weather_model.dart';

// Contract for remote data fetching
abstract class WeatherRemoteDataSource {
  Future<WeatherModel> getCurrentWeather(String cityName);
}
  • Below the abstract class in the same file, add the implementation:
// Concrete implementation that talks to OpenWeatherMap
class WeatherRemoteDataSourceImpl implements WeatherRemoteDataSource {
  final http.Client client;
  final String apiKey;

  const WeatherRemoteDataSourceImpl({required this.client, required this.apiKey});

  @override
  Future<WeatherModel> getCurrentWeather(String cityName) async {
    final uri = Uri.parse(
      '${ApiConstants.baseUrl}${ApiConstants.currentWeatherPath}?q=$cityName&units=metric&appid=$apiKey',
    );

    try {
      final response = await client.get(uri);

      if (response.statusCode == 200) {
        final json = jsonDecode(response.body) as Map<String, dynamic>;
        return WeatherModel.fromJson(json);
      } else {
        throw ServerException('Server responded with status: ${response.statusCode}');
      }
    } on SocketException {
      throw const NetworkException('No internet connection');
    }
  }
}

What does this code do?

The datasource builds the full URL using constants from ApiConstants and makes an HTTP GET request. On a 200 response, it parses the JSON into a WeatherModel.

On a non-200 status it throws ServerException. On a SocketException (no network), it throws NetworkException. The repository layer above will catch these and convert them to typed Failures.

  • Save weather_remote_datasource.dart.
  • Create a new file at lib/features/weather/data/repositories/weather_repository_impl.dart and add this code:
import '../../../../core/error/exceptions.dart';
import '../../../../core/error/failures.dart';
import '../../domain/entities/weather_entity.dart';
import '../../domain/repositories/weather_repository.dart';
import '../datasources/weather_remote_datasource.dart';

// Catches raw exceptions and returns typed Failures to the domain
class WeatherRepositoryImpl implements WeatherRepository {
  final WeatherRemoteDataSource remoteDataSource;

  const WeatherRepositoryImpl({required this.remoteDataSource});

  @override
  Future<({WeatherEntity? data, Failure? failure})> getCurrentWeather(String cityName) async {
    try {
      final model = await remoteDataSource.getCurrentWeather(cityName);
      return (data: model, failure: null);
    } on ServerException catch (e) {
      return (data: null, failure: ServerFailure(e.message));
    } on NetworkException catch (e) {
      return (data: null, failure: NetworkFailure(e.message));
    }
  }
}

What does this code do?

The repository wraps the datasource call in a try/catch. If the call succeeds, it returns the data with failure: null. If it throws, the repository converts the raw exception into a domain-friendly Failure object.

This is the boundary between "things that throw" (data layer) and "things that return results" (domain layer). No exception leaks past the repository.

  • Save weather_repository_impl.dart, then verify the entire data layer compiles:
dart analyze lib/features/weather/data/

You should see No issues found! confirming all imports resolve and types align.

Seeing unresolved imports in the datasource?

Make sure api_constants.dart is at lib/core/network/api_constants.dart. The datasource imports it four levels up. If the file is in the wrong folder, the import path breaks.

help me fix unresolved import errors in my weather data layer

✔️ Awesome, I've got everything!

Great. Your full data layer is wired up. Every file should be saved.

ⓧ I'd like to double check the full code

Here is lib/core/network/api_constants.dart:

class ApiConstants {
  static const String apiKey = 'your-api-key-here';
  static const String baseUrl = 'https://api.openweathermap.org/data/2.5';
  static const String currentWeatherPath = '/weather';
  static const String forecastPath = '/forecast';
}

Here is lib/features/weather/data/models/weather_model.dart:

import '../../domain/entities/weather_entity.dart';

class WeatherModel extends WeatherEntity {
  const WeatherModel({
    required super.cityName,
    required super.temperature,
    required super.condition,
    required super.icon,
    required super.windSpeed,
    required super.humidity,
  });

  factory WeatherModel.fromJson(Map<String, dynamic> json) {
    return WeatherModel(
      cityName: json['name'] as String,
      temperature: (json['main']['temp'] as num).toDouble(),
      condition: json['weather'][0]['description'] as String,
      icon: json['weather'][0]['icon'] as String,
      windSpeed: (json['wind']['speed'] as num).toDouble(),
      humidity: json['main']['humidity'] as int,
    );
  }
}

Here is lib/features/weather/data/datasources/weather_remote_datasource.dart:

import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../../../../core/error/exceptions.dart';
import '../../../../core/network/api_constants.dart';
import '../models/weather_model.dart';

abstract class WeatherRemoteDataSource {
  Future<WeatherModel> getCurrentWeather(String cityName);
}

class WeatherRemoteDataSourceImpl implements WeatherRemoteDataSource {
  final http.Client client;
  final String apiKey;

  const WeatherRemoteDataSourceImpl({required this.client, required this.apiKey});

  @override
  Future<WeatherModel> getCurrentWeather(String cityName) async {
    final uri = Uri.parse(
      '${ApiConstants.baseUrl}${ApiConstants.currentWeatherPath}?q=$cityName&units=metric&appid=$apiKey',
    );

    try {
      final response = await client.get(uri);

      if (response.statusCode == 200) {
        final json = jsonDecode(response.body) as Map<String, dynamic>;
        return WeatherModel.fromJson(json);
      } else {
        throw ServerException('Server responded with status: ${response.statusCode}');
      }
    } on SocketException {
      throw const NetworkException('No internet connection');
    }
  }
}

Here is lib/features/weather/data/repositories/weather_repository_impl.dart:

import '../../../../core/error/exceptions.dart';
import '../../../../core/error/failures.dart';
import '../../domain/entities/weather_entity.dart';
import '../../domain/repositories/weather_repository.dart';
import '../datasources/weather_remote_datasource.dart';

class WeatherRepositoryImpl implements WeatherRepository {
  final WeatherRemoteDataSource remoteDataSource;

  const WeatherRepositoryImpl({required this.remoteDataSource});

  @override
  Future<({WeatherEntity? data, Failure? failure})> getCurrentWeather(String cityName) async {
    try {
      final model = await remoteDataSource.getCurrentWeather(cityName);
      return (data: model, failure: null);
    } on ServerException catch (e) {
      return (data: null, failure: ServerFailure(e.message));
    } on NetworkException catch (e) {
      return (data: null, failure: NetworkFailure(e.message));
    }
  }
}
Wire up your OpenWeatherMap API key

The datasource needs a real API key to fetch weather data. OpenWeatherMap offers a free tier with 60 calls per minute and 1,000,000 calls per month. No billing info required.

  • Open https://home.openweathermap.org/users/sign_in in your browser.
  • Create a free account (or sign in if you already have one).
  • After signing in, click the API keys tab in your account dashboard.
  • Copy your API key (it looks like a long string of letters and numbers).

API key activation can take up to 2 hours

OpenWeatherMap documents that new API keys may take up to 2 hours to activate after registration. If you just signed up, your key might not work immediately. Continue with the next steps and retry if you get a 401 response.

  • In lib/core/network/api_constants.dart, find this line:
static const String apiKey = 'your-api-key-here';
  • Replace your-api-key-here with the API key you copied from your OpenWeatherMap dashboard, so the line looks like this:
static const String apiKey = 'your-api-key-here';
  • Save api_constants.dart.

Now prove the entire vertical slice works by making a live API call. Before you run the test, think about what you expect to see. Do you think the request will return weather data, or will something go wrong?

  • Create a temporary file at lib/scratch_test.dart and add this code:
import 'package:http/http.dart' as http;
import 'core/network/api_constants.dart';
import 'features/weather/data/datasources/weather_remote_datasource.dart';

// Quick test to verify the datasource connects to the live API
void main() async {
  final datasource = WeatherRemoteDataSourceImpl(
    client: http.Client(),
    apiKey: ApiConstants.apiKey,
  );

  try {
    final weather = await datasource.getCurrentWeather('London');
    print('City: ${weather.cityName}');
    print('Temperature: ${weather.temperature} C');
    print('Condition: ${weather.condition}');
    print('Wind: ${weather.windSpeed} m/s');
    print('Humidity: ${weather.humidity}%');
  } catch (e) {
    print('Error: $e');
  }
}
  • Save scratch_test.dart, then run it:
dart run lib/scratch_test.dart

You should see live weather data for London printed in your terminal. Something like:

City: London
Temperature: 18.5 C
Condition: scattered clouds
Wind: 4.12 m/s
Humidity: 72%

The exact numbers will differ based on the current weather. The important thing is that you see real data flowing through every layer: HTTP client, datasource, model parsing, and entity fields.

Getting a 401 Unauthorized error?

Your API key may not be activated yet. OpenWeatherMap keys can take up to 2 hours after registration. Wait and retry.

Also verify that you pasted the key correctly in api_constants.dart with no extra spaces or quotes.

help me debug my OpenWeatherMap API key returning 401

  • Once you see weather data in the terminal, delete lib/scratch_test.dart. It was only needed to verify the wiring.

Your full vertical slice is proven. Data flows from the OpenWeatherMap API through the datasource, into the repository, and arrives as a typed entity. Next up, you will wire in BLoC state management and dependency injection so the UI can consume this data.

Build the BLoC and Wire Dependency Injection

Your domain layer defines what the app does, and your data layer defines how it fetches data. But right now, nothing connects these layers to the screen.

The BLoC pattern bridges this gap. It receives events from the UI, calls the use case, and emits typed state changes the UI reacts to. get_it wires all layers together so data flows from the API through every layer to the screen.

In this step, get ready to:
  • Create the WeatherBloc with typed events and states.
  • Wire all layers together using get_it dependency injection.
  • Prove the full stack works by displaying live weather data on screen.
Create the WeatherBloc

A BLoC has three parts: events (what happened), states (what to show), and the bloc itself (the logic connecting them). You will create each as its own file using Dart's part directive.

  • Create the file lib/features/weather/presentation/bloc/weather_event.dart and paste this code:
part of 'weather_bloc.dart';

sealed class WeatherEvent extends Equatable {
  const WeatherEvent();

  @override
  List<Object> get props => [];
}

class FetchWeather extends WeatherEvent {
  final String cityName;
  const FetchWeather(this.cityName);

  @override
  List<Object> get props => [cityName];
}

What does this code do?

This defines the events your BLoC can receive. Right now there is only one: FetchWeather, which carries a city name.

The sealed keyword means Dart can check at compile time that your bloc handles every event type. Equatable lets the BLoC compare events by value, preventing duplicate processing.

  • Create the file lib/features/weather/presentation/bloc/weather_state.dart and paste this code:
part of 'weather_bloc.dart';

sealed class WeatherState extends Equatable {
  const WeatherState();

  @override
  List<Object> get props => [];
}

class WeatherInitial extends WeatherState {}

class WeatherLoading extends WeatherState {}

class WeatherLoaded extends WeatherState {
  final WeatherEntity weather;
  const WeatherLoaded(this.weather);

  @override
  List<Object> get props => [weather];
}

class WeatherError extends WeatherState {
  final String message;
  const WeatherError(this.message);

  @override
  List<Object> get props => [message];
}

What does this code do?

These are the four possible states your weather screen can be in. WeatherInitial is the starting state before anything happens. WeatherLoading means a fetch is in progress.

WeatherLoaded holds a WeatherEntity with the actual data. WeatherError holds an error message. The UI will render a different widget for each state.

  • Confirm both files exist by running:
ls lib/features/weather/presentation/bloc/

You should see weather_event.dart and weather_state.dart listed in the output.

  • Create the file lib/features/weather/presentation/bloc/weather_bloc.dart and paste this code:
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import '../../domain/entities/weather_entity.dart';
import '../../domain/usecases/get_current_weather.dart';

part 'weather_event.dart';
part 'weather_state.dart';

class WeatherBloc extends Bloc<WeatherEvent, WeatherState> {
  final GetCurrentWeather getCurrentWeather;

  WeatherBloc({required this.getCurrentWeather}) : super(WeatherInitial()) {
    on<FetchWeather>(_onFetchWeather);
  }

  Future<void> _onFetchWeather(FetchWeather event, Emitter<WeatherState> emit) async {
    emit(WeatherLoading());
    final result = await getCurrentWeather(event.cityName);
    if (result.data != null) {
      emit(WeatherLoaded(result.data!));
    } else {
      emit(WeatherError(result.failure?.message ?? 'Unknown error'));
    }
  }
}

What does this code do?

The part directives pull in your event and state files so they share the same library scope. The on<FetchWeather> call registers a handler for the FetchWeather event.

When _onFetchWeather runs, it emits WeatherLoading, calls the use case, and then emits either WeatherLoaded or WeatherError depending on the result. The BLoC never knows where the data comes from. It only talks to the use case.

  • Verify all three BLoC files compile cleanly by running:
flutter analyze

You should see No issues found! in the output.

Seeing analysis errors?

  • Make sure all three files are saved in the same lib/features/weather/presentation/bloc/ directory. The part directives use relative paths.
  • Check that weather_event.dart and weather_state.dart both start with part of 'weather_bloc.dart'; (not import).
  • help me fix flutter analyze errors in my BLoC files

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files in lib/features/weather/presentation/bloc/.

ⓧ I'd like to double check the full code

You should have three files in the bloc folder. Scroll up and confirm each one matches:

  • weather_event.dart (the events chunk).
  • weather_state.dart (the states chunk).
  • weather_bloc.dart (the bloc class chunk).
Wire dependency injection with get_it

The BLoC needs a use case. The use case needs a repository. The repository needs a data source. The data source needs an HTTP client. get_it acts as a central registry where each dependency is created once and handed to whatever needs it.

  • Create the directory lib/app/router/ if it does not exist, then create lib/app/router/auth_service.dart with this code:
class AuthService {
  bool isAuthenticated = false;

  void login() {
    isAuthenticated = true;
  }

  void logout() {
    isAuthenticated = false;
  }
}

Why create AuthService now?

The dependency injection container registers every service the app will use, including AuthService. Creating it now means the DI container compiles without missing imports. You will use this service for route guards in the next step.

  • Create the file lib/app/di/injection_container.dart and paste the imports and global instance:
import 'package:get_it/get_it.dart';
import 'package:http/http.dart' as http;
import '../../core/network/api_constants.dart';
import '../../features/weather/data/datasources/weather_remote_datasource.dart';
import '../../features/weather/data/repositories/weather_repository_impl.dart';
import '../../features/weather/domain/repositories/weather_repository.dart';
import '../../features/weather/domain/usecases/get_current_weather.dart';
import '../../features/weather/presentation/bloc/weather_bloc.dart';
import '../router/auth_service.dart';

final getIt = GetIt.instance;

What does this code do?

The getIt variable is the single service locator instance your entire app shares. The imports bring in every class that will be registered so the container knows about all layers.

  • Below the getIt declaration in the same file, add the configureDependencies function:
void configureDependencies() {
  getIt.registerLazySingleton<http.Client>(() => http.Client());

  getIt.registerLazySingleton<AuthService>(() => AuthService());

  getIt.registerLazySingleton<WeatherRemoteDataSource>(
    () => WeatherRemoteDataSourceImpl(client: getIt(), apiKey: ApiConstants.apiKey),
  );

  getIt.registerLazySingleton<WeatherRepository>(
    () => WeatherRepositoryImpl(remoteDataSource: getIt()),
  );

  getIt.registerLazySingleton<GetCurrentWeather>(
    () => GetCurrentWeather(repository: getIt()),
  );

  getIt.registerFactory<WeatherBloc>(
    () => WeatherBloc(getCurrentWeather: getIt()),
  );
}

What does this code do?

Each registerLazySingleton call creates one shared instance the first time it is requested. The registration order matters because each layer depends on the one registered before it.

WeatherBloc uses registerFactory instead. This creates a fresh BLoC instance every time it is requested, which prevents stale state from leaking between screens.

  • Save injection_container.dart and verify the container compiles by running:
flutter analyze

You should see No issues found! confirming every import resolves and every registered class exists.

Seeing import errors?

  • Confirm that lib/app/router/auth_service.dart exists. The DI container imports it from ../router/auth_service.dart.
  • Make sure the lib/app/di/ directory exists. You created it in Step 1.
  • help me fix import errors in my injection container

✔️ Awesome, I've got everything!

Great. Make sure injection_container.dart and auth_service.dart are both saved.

ⓧ I'd like to double check the full code

Your DI container is split across two chunks above. Scroll up and confirm both are present in order:

  • The imports and final getIt = GetIt.instance; declaration.
  • The configureDependencies() function with all six registrations.
Prove the wiring works

The BLoC exists and the container knows how to build it. Now you need to call configureDependencies() at app startup and provide the BLoC to the widget tree so it can fetch real weather data.

  • Replace the entire contents of lib/main.dart with this code:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'app/di/injection_container.dart';
import 'features/weather/presentation/bloc/weather_bloc.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  configureDependencies();
  runApp(const WeatherEngineApp());
}

class WeatherEngineApp extends StatelessWidget {
  const WeatherEngineApp({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocProvider<WeatherBloc>(
      create: (_) => getIt<WeatherBloc>()..add(const FetchWeather('London')),
      child: MaterialApp(
        title: 'Weather Engine',
        theme: ThemeData(
          colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
          useMaterial3: true,
        ),
        home: Scaffold(
          body: BlocBuilder<WeatherBloc, WeatherState>(
            builder: (context, state) => Center(child: Text(state.toString())),
          ),
        ),
      ),
    );
  }
}

What does this code do?

configureDependencies() runs before runApp, registering every service in the container. Then BlocProvider pulls a fresh WeatherBloc from get_it and immediately dispatches FetchWeather('London').

The BlocBuilder rebuilds its child whenever the state changes. Right now it simply calls state.toString() to display the raw state object. This is temporary. You will build proper UI widgets in a later step.

  • Save lib/main.dart.

Before you run the app, what do you think the screen will display? A loading spinner, an error, or actual weather data?

  • Run the app in Chrome by running:
flutter run -d chrome

You should see text on screen that reads something like WeatherLoaded(WeatherEntity(...)) with a real temperature value for London. That raw state text proves every layer is connected end-to-end.

Seeing WeatherError instead of WeatherLoaded?

  • If you see a network error, confirm your machine has an internet connection.
  • If you see a server error with status 401, your API key in lib/core/network/api_constants.dart is either incorrect or has not activated yet. OpenWeatherMap keys can take up to 2 hours to activate after signup.
  • If you see a blank screen or a red error overlay, stop the app with Ctrl+C and run flutter analyze to check for typos.
  • help me debug why my weather app shows an error instead of weather data

✔️ Awesome, I've got everything!

Great. Confirm you see real temperature data in the WeatherLoaded output on screen.

ⓧ I'd like to double check the full code

Your lib/main.dart should look like this:

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'app/di/injection_container.dart';
import 'features/weather/presentation/bloc/weather_bloc.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  configureDependencies();
  runApp(const WeatherEngineApp());
}

class WeatherEngineApp extends StatelessWidget {
  const WeatherEngineApp({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocProvider<WeatherBloc>(
      create: (_) => getIt<WeatherBloc>()..add(const FetchWeather('London')),
      child: MaterialApp(
        title: 'Weather Engine',
        theme: ThemeData(
          colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
          useMaterial3: true,
        ),
        home: Scaffold(
          body: BlocBuilder<WeatherBloc, WeatherState>(
            builder: (context, state) => Center(child: Text(state.toString())),
          ),
        ),
      ),
    );
  }
}

Your architecture is now fully wired. Data flows from the OpenWeatherMap API through the data source, repository, use case, and BLoC, all the way to the screen. Next up, you will add GoRouter with bottom navigation and a route guard that blocks unauthenticated access to the Settings tab.

Add GoRouter with Shell Navigation and Route Guard

Your app fetches live weather data and displays it as raw state text. That proves the full architecture works end-to-end from UI through BLoC, UseCase, Repository, and DataSource.

A production app needs more than one screen. It needs persistent navigation chrome (a bottom bar that stays visible across pages) and protected routes that block unauthorized access. In this step you will add both using GoRouter.

In this step, get ready to:
  • Set up GoRouter with StatefulShellRoute for persistent bottom navigation.
  • Add an onEnter guard that blocks the Settings route for unauthenticated users.
  • Verify the guard blocks and then allows access after login.
Set up GoRouter with StatefulShellRoute

GoRouter's StatefulShellRoute gives you a shell widget (with a bottom nav bar) that persists across branch navigations. Each branch maintains its own navigator stack independently.

  • Create lib/features/weather/presentation/pages/settings_page.dart with the following code:
import 'package:flutter/material.dart';

class SettingsPage extends StatelessWidget {
  const SettingsPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Settings')),
      body: const Center(
        child: Text(
          'Settings Page\n(Protected by onEnter guard)',
          textAlign: TextAlign.center,
          style: TextStyle(fontSize: 18),
        ),
      ),
    );
  }
}

What does this code do?

This is a placeholder page for the Settings tab. It displays a centered label confirming the route is protected by the onEnter guard you will add shortly.

  • Create lib/features/weather/presentation/pages/home_page.dart with this initial version:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/weather_bloc.dart';

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Weather Engine')),
      body: BlocBuilder<WeatherBloc, WeatherState>(
        builder: (context, state) => Center(
          child: Text(state.toString()),
        ),
      ),
    );
  }
}

What does this code do?

The HomePage renders the current WeatherBloc state as text. This is the same debug display you had in main.dart, now extracted into its own page widget that the router can mount inside the navigation shell.

  • Save both files.
  • Confirm the page files exist by running:
ls lib/features/weather/presentation/pages/

You should see home_page.dart and settings_page.dart in the output.

  • Create lib/app/router/app_router.dart and start with the imports, navigator keys, and the GoRouter configuration:
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../di/injection_container.dart';
import '../../features/weather/presentation/pages/home_page.dart';
import '../../features/weather/presentation/pages/settings_page.dart';
import 'auth_service.dart';

// Navigator keys for shell and root navigators
final _rootNavigatorKey = GlobalKey<NavigatorState>();
final _shellNavigatorKey = GlobalKey<NavigatorState>();

// GoRouter with StatefulShellRoute for persistent bottom nav
final appRouter = GoRouter(
  navigatorKey: _rootNavigatorKey,
  initialLocation: '/',
  routes: [
    StatefulShellRoute.indexedStack(
      builder: (context, state, navigationShell) {
        return ScaffoldWithNavBar(navigationShell: navigationShell);
      },
      branches: [
        StatefulShellBranch(
          navigatorKey: _shellNavigatorKey,
          routes: [
            GoRoute(
              path: '/',
              builder: (context, state) => const HomePage(),
            ),
          ],
        ),

What does this code do?

The two GlobalKey<NavigatorState> instances give GoRouter separate navigator stacks for the root and the shell. This lets each tab maintain its own navigation history.

StatefulShellRoute.indexedStack creates a persistent shell with an IndexedStack under the hood. The first branch serves / (Home). You will add the second branch next.

  • Continue in the same file. Add the second branch and close the GoRouter, then add the ScaffoldWithNavBar class below it:
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/settings',
              builder: (context, state) => const SettingsPage(),
            ),
          ],
        ),
      ],
    ),
  ],
);

// Shell widget rendering persistent bottom navigation
class ScaffoldWithNavBar extends StatelessWidget {
  final StatefulNavigationShell navigationShell;

  const ScaffoldWithNavBar({super.key, required this.navigationShell});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: navigationShell,
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: navigationShell.currentIndex,
        onTap: (index) => navigationShell.goBranch(index),
        items: const [
          BottomNavigationBarItem(icon: Icon(Icons.cloud), label: 'Weather'),
          BottomNavigationBarItem(icon: Icon(Icons.settings), label: 'Settings'),
        ],
      ),
    );
  }
}

What does ScaffoldWithNavBar do?

ScaffoldWithNavBar is the shell widget that wraps every page. It renders the BottomNavigationBar with Weather and Settings tabs. Tapping a tab calls navigationShell.goBranch(index) so GoRouter handles the actual route switching.

Because each branch preserves its own navigator stack, switching tabs never destroys scroll position or state.

  • Save app_router.dart.
  • Now update lib/main.dart to use the router. Replace the entire file with:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'app/di/injection_container.dart';
import 'app/router/app_router.dart';
import 'features/weather/presentation/bloc/weather_bloc.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  configureDependencies();
  runApp(const WeatherEngineApp());
}

class WeatherEngineApp extends StatelessWidget {
  const WeatherEngineApp({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocProvider<WeatherBloc>(
      create: (_) => getIt<WeatherBloc>()..add(const FetchWeather('London')),
      child: MaterialApp.router(
        title: 'Weather Engine',
        theme: ThemeData(
          colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
          useMaterial3: true,
        ),
        routerConfig: appRouter,
      ),
    );
  }
}

What changed in main.dart?

MaterialApp became MaterialApp.router with routerConfig: appRouter. This hands all navigation decisions to GoRouter instead of the default Navigator.

The inline Scaffold and BlocBuilder are gone. The router now decides which page to show, and each page handles its own layout.

  • Save main.dart and run the app by running:
flutter run -d chrome

You should see a bottom navigation bar with a Weather tab and a Settings tab. Tap between them freely. Both tabs navigate without restrictions.

Don't see the bottom navigation bar?

  • Check that app_router.dart has both the appRouter variable AND the ScaffoldWithNavBar class in the same file.
  • Confirm main.dart uses MaterialApp.router (not plain MaterialApp). The routerConfig parameter only exists on the .router constructor.
  • Verify the import path import 'app/router/app_router.dart' matches the actual file location.

help me debug my GoRouter bottom navigation not showing

The Settings tab is wide open right now. Any user can tap it and see the page. In a production app, that is a security gap.

Add the onEnter guard

GoRouter's onEnter callback fires before any navigation completes. You can inspect the target route and return GoRouterOnEnterResult.block to prevent the navigation entirely.

  • In app_router.dart, find this line inside the GoRouter constructor:
  initialLocation: '/',
  • Add the following onEnter callback directly below it (before the routes: line):
  onEnter: (context, state) {
    if (state.matchedLocation == '/settings') {
      final authService = getIt<AuthService>();
      if (!authService.isAuthenticated) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('Please log in first')),
        );
        return GoRouterOnEnterResult.block;
      }
    }
    return GoRouterOnEnterResult.allow;
  },

What does the onEnter guard do?

Every time GoRouter is about to navigate, onEnter fires first. If the target is /settings and AuthService.isAuthenticated is false, it shows a snackbar and returns GoRouterOnEnterResult.block to cancel navigation.

For all other routes (or if the user is authenticated), it returns GoRouterOnEnterResult.allow and navigation proceeds normally.

  • Save app_router.dart.
  • Now update home_page.dart to add a Login button so you can toggle authentication. Replace the entire file with:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../app/di/injection_container.dart';
import '../../../../app/router/auth_service.dart';
import '../bloc/weather_bloc.dart';

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Weather Engine')),
      body: Column(
        children: [
          BlocBuilder<WeatherBloc, WeatherState>(
            builder: (context, state) =>
                Center(child: Text(state.toString())),
          ),
          ElevatedButton.icon(
            onPressed: () {
              getIt<AuthService>().login();
              ScaffoldMessenger.of(context).showSnackBar(
                const SnackBar(
                    content: Text(
                        'Logged in! You can now access Settings.')),
              );
            },
            icon: const Icon(Icons.login),
            label: const Text('Simulate Login'),
          ),
        ],
      ),
    );
  }
}

What does the login button do?

The button calls getIt<AuthService>().login() which sets isAuthenticated = true on the singleton AuthService. The next time onEnter checks the AuthService, it sees the user is authenticated and returns GoRouterOnEnterResult.allow.

  • Save home_page.dart.

Seeing a compilation error?

  • Make sure you have the import for '../../../../app/di/injection_container.dart' at the top. The getIt accessor lives there.
  • Check that the import path to auth_service.dart uses '../../../../app/router/auth_service.dart'. Count the directory levels carefully from pages/ back to lib/.

help me fix import errors in my Flutter home page

Verify guard behavior

Before you run the app, predict what will happen when you tap the Settings tab. Will it navigate to the Settings page, or will something block it?

  • Hot restart the app (press R in the terminal, or stop and re-run with flutter run -d chrome).
  • Tap the Settings tab in the bottom navigation bar.

You are blocked. A snackbar appears at the bottom with the message "Please log in first" and you stay on the Weather tab.

  • Tap the Simulate Login button on the Home screen.
  • You see a snackbar confirming "Logged in! You can now access Settings."
  • Tap the Settings tab again.

This time it navigates successfully. You see the Settings page with its "Protected by onEnter guard" label. The guard pattern works.

Why onEnter instead of redirect?

GoRouter also supports redirect for rerouting users to a different path. The onEnter callback is different because it can flat-out block navigation without redirecting anywhere.

This makes it ideal for cases where you want the user to stay where they are and see a message, rather than being silently rerouted to a login page.

Settings tab still accessible without login?

  • Make sure you did a full hot restart (not just hot reload). Hot reload does not reinitialize singletons, so AuthService.isAuthenticated might still be true from a previous run.
  • Confirm the onEnter callback is placed inside the GoRouter( constructor, between initialLocation and routes.
  • Check that state.matchedLocation == '/settings' uses a double equals, not a single equals.

help me debug why my onEnter guard is not blocking the settings route

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files: app_router.dart, home_page.dart, and main.dart.

ⓧ I'd like to double check the full code

Your project now has these key files. Scroll up and confirm each chunk is present in order.

  • lib/app/router/app_router.dart contains: imports, navigator keys, the appRouter GoRouter config with onEnter and StatefulShellRoute.indexedStack, and the ScaffoldWithNavBar class.
  • lib/features/weather/presentation/pages/home_page.dart contains: a Scaffold with BlocBuilder and the Simulate Login button that calls getIt<AuthService>().login().
  • lib/features/weather/presentation/pages/settings_page.dart contains: a simple centered Text widget.
  • lib/main.dart uses MaterialApp.router with routerConfig: appRouter.

Your app now has declarative routing with a persistent bottom nav and a working auth guard. Next up, you will replace the raw state text with polished UI widgets and proper error handling.

Build the Weather UI and Error Handling

Your entire Clean Architecture stack is wired and proven. Data flows from the API through the repository and use case, into the BLoC, and renders as raw state text on screen.

Raw state text is fine for debugging, but a production app needs proper visual states. A loading spinner while data fetches. A styled card when data arrives. An error card when the network fails. This step replaces the debug output with real presentation widgets that handle every state gracefully.

In this step, get ready to:
  • Build WeatherCard and WeatherErrorCard presentation widgets.
  • Compose the HomePage with BlocConsumer to render the correct widget per state.
  • Test error propagation by disconnecting the network and seeing a styled error card instead of a crash.
Build the WeatherCard widget

The WeatherCard is responsible for displaying the full weather data when the BLoC emits a WeatherLoaded state. It renders the city name, a condition icon from OpenWeatherMap, temperature, condition text, wind speed, and humidity.

  • Create a new file at lib/features/weather/presentation/widgets/weather_card.dart and add the following code:
import 'package:flutter/material.dart';
import '../../domain/entities/weather_entity.dart';

class WeatherCard extends StatelessWidget {
  final WeatherEntity weather;

  const WeatherCard({super.key, required this.weather});

  @override
  Widget build(BuildContext context) {
    return Card(
      elevation: 4,
      child: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          children: [
            Text(weather.cityName, style: Theme.of(context).textTheme.headlineSmall),
            const SizedBox(height: 8),
            Image.network(
              'https://openweathermap.org/img/wn/${weather.icon}@2x.png',
              width: 80,
              height: 80,
            ),
            Text(
              '${weather.temperature.toStringAsFixed(1)} C',
              style: Theme.of(context).textTheme.displaySmall,
            ),
            const SizedBox(height: 4),
            Text(weather.condition, style: Theme.of(context).textTheme.titleMedium),
            const SizedBox(height: 16),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
              children: [
                _InfoChip(icon: Icons.air, label: '${weather.windSpeed} m/s'),
                _InfoChip(icon: Icons.water_drop, label: '${weather.humidity}%'),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

What does this widget do?

  • Card wraps everything in an elevated Material card.
  • Image.network fetches the weather condition icon directly from OpenWeatherMap using the icon code stored in WeatherEntity.
  • toStringAsFixed(1) formats the temperature to one decimal place.
  • The Row at the bottom uses a private _InfoChip helper (which you will add next) to display wind speed and humidity side by side.
  • Below the closing } of WeatherCard, add the _InfoChip helper class:
class _InfoChip extends StatelessWidget {
  final IconData icon;
  final String label;

  const _InfoChip({required this.icon, required this.label});

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        Icon(icon, size: 18, color: Colors.grey),
        const SizedBox(width: 4),
        Text(label),
      ],
    );
  }
}

What is _InfoChip?

The underscore prefix makes this class private to the file. It is a small reusable row that pairs an icon with a text label. WeatherCard uses two of them (wind and humidity) so extracting this avoids duplicating the layout code.

  • Save weather_card.dart.
  • Check your terminal. The app should hot-reload with no compilation errors.

Seeing a compile error?

  • Make sure _InfoChip is placed BELOW the closing brace of WeatherCard, not inside it.
  • Verify the import path matches your folder structure exactly: ../../domain/entities/weather_entity.dart.

Why is my WeatherCard not compiling?

Build the WeatherErrorCard widget

When the network is unreachable, the BLoC emits a WeatherError state. The app needs a dedicated widget to display the error message and offer a retry action.

  • Create a new file at lib/features/weather/presentation/widgets/weather_error_card.dart and add the following code:
import 'package:flutter/material.dart';

class WeatherErrorCard extends StatelessWidget {
  final String message;
  final VoidCallback onRetry;

  const WeatherErrorCard({super.key, required this.message, required this.onRetry});

  @override
  Widget build(BuildContext context) {
    return Card(
      color: Theme.of(context).colorScheme.errorContainer,
      child: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          children: [
            Icon(Icons.error_outline, size: 48, color: Theme.of(context).colorScheme.error),
            const SizedBox(height: 12),
            Text(message, textAlign: TextAlign.center),
            const SizedBox(height: 16),
            FilledButton.icon(
              onPressed: onRetry,
              icon: const Icon(Icons.refresh),
              label: const Text('Retry'),
            ),
          ],
        ),
      ),
    );
  }
}

What does this widget do?

  • colorScheme.errorContainer uses Material 3's semantic error colour so the card visually signals a problem.
  • onRetry is a callback the parent passes in. When tapped, it dispatches a new FetchWeather event so the user can try again.
  • FilledButton.icon gives the retry action a prominent, accessible tap target.
  • Save weather_error_card.dart.
  • Confirm the terminal shows no new errors after hot-reload.

✔️ Awesome, I've got everything!

Great. Both presentation widgets are ready. Next you will wire them into the HomePage.

ⓧ I'd like to double check the full code

Scroll up and confirm you have these two chunks in weather_card.dart, in order:

  • The WeatherCard class (imports through the closing brace).
  • The _InfoChip class immediately below.

And confirm weather_error_card.dart contains the full WeatherErrorCard class as shown above.

Compose the HomePage with BlocConsumer

Right now the HomePage renders raw state.toString() text. You will replace the page body with a BlocConsumer that renders the correct widget for each state, wrapped in a RefreshIndicator for pull-to-refresh.

  • Open lib/features/weather/presentation/pages/home_page.dart and replace the entire file contents with:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../app/di/injection_container.dart';
import '../../../../app/router/auth_service.dart';
import '../bloc/weather_bloc.dart';
import '../widgets/weather_card.dart';
import '../widgets/weather_error_card.dart';

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Weather Engine')),
      body: RefreshIndicator(
        onRefresh: () async {
          context.read<WeatherBloc>().add(const FetchWeather('London'));
        },
        child: SingleChildScrollView(
          physics: const AlwaysScrollableScrollPhysics(),
          padding: const EdgeInsets.all(16),
          child: Column(
            children: [
              BlocConsumer<WeatherBloc, WeatherState>(
                listener: (context, state) {
                  if (state is WeatherError) {
                    ScaffoldMessenger.of(context).showSnackBar(
                      SnackBar(content: Text(state.message)),
                    );
                  }
                },

What is happening so far?

  • RefreshIndicator enables pull-to-refresh. When triggered it dispatches FetchWeather('London') to reload data.
  • AlwaysScrollableScrollPhysics() ensures the scroll view is always scrollable so the pull gesture works even when content is short.
  • BlocConsumer combines a listener (for side effects like snackbars) and a builder (for rendering widgets based on state).
  • Directly below the listener, add the builder and the rest of the page:
                builder: (context, state) {
                  if (state is WeatherLoading) {
                    return const Center(child: CircularProgressIndicator());
                  } else if (state is WeatherLoaded) {
                    return WeatherCard(weather: state.weather);
                  } else if (state is WeatherError) {
                    return WeatherErrorCard(
                      message: state.message,
                      onRetry: () => context.read<WeatherBloc>().add(const FetchWeather('London')),
                    );
                  }
                  return const SizedBox.shrink();
                },
              ),
              const SizedBox(height: 24),
              ElevatedButton.icon(
                onPressed: () {
                  getIt<AuthService>().login();
                  ScaffoldMessenger.of(context).showSnackBar(
                    const SnackBar(content: Text('Logged in! You can now access Settings.')),
                  );
                },
                icon: const Icon(Icons.login),
                label: const Text('Simulate Login'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

What does the builder do?

  • WeatherLoading renders a CircularProgressIndicator so the user sees a spinner while data loads.
  • WeatherLoaded renders the WeatherCard you just built, passing the entity data through.
  • WeatherError renders WeatherErrorCard with a retry callback that re-dispatches FetchWeather.
  • The Simulate Login button from earlier remains at the bottom of the Column.
  • Save home_page.dart.

Before you look at the result, what do you expect to see on the Home tab now that the BlocConsumer renders widgets instead of raw text?

  • Check your Chrome window. The app hot-reloads automatically.

You should see a styled card showing London's current temperature (a large number), a weather condition icon, the condition description, and wind speed and humidity chips at the bottom.

Seeing a blank screen or compile error?

  • Confirm that weather_card.dart and weather_error_card.dart are saved. The imports in home_page.dart reference both files.
  • Check that the two code chunks in home_page.dart are placed as one continuous file. The builder: key belongs inside the same BlocConsumer call as the listener: key.
  • If you see a network icon error image instead of the weather icon, your API key may still be activating. The rest of the card data should display correctly.

Why is my HomePage showing a blank screen after adding BlocConsumer?

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files.

ⓧ I'd like to double check the full code

Confirm home_page.dart contains both chunks as one continuous file. Scroll up and verify:

  • The imports block (7 import statements including weather_card.dart and weather_error_card.dart).
  • The Scaffold body wraps RefreshIndicator > SingleChildScrollView > Column.
  • BlocConsumer has both a listener and a builder inside the same widget call.
  • The ElevatedButton.icon (Simulate Login) appears after a SizedBox(height: 24) inside the Column.
Test error propagation

A production app must not crash when the network drops. Your architecture already translates SocketException into a NetworkFailure at the repository layer. Now you will see that failure render cleanly as a styled error card in the UI.

Before you test, what do you think will happen if the network is unreachable when you pull to refresh?

  • Enable airplane mode on your computer (or disconnect WiFi).
  • In Chrome, pull down on the weather page to trigger a refresh.

You should see the WeatherErrorCard appear with a red-tinted background, an error icon, and a message. A snackbar also appears briefly at the bottom of the screen. The app did not crash.

  • Re-enable your network connection.
  • Tap the Retry button on the error card (or pull to refresh again).

The WeatherCard returns with live data. The full cycle proves that typed Failure objects propagate from the data layer through the BLoC and render as a recoverable UI state.

Error card not appearing?

  • Chrome's DevTools can override network state. Open DevTools, go to the Network tab, and check the Offline checkbox instead of using system airplane mode.
  • Make sure you are pulling to refresh (swipe down) after disconnecting. The initial load may have succeeded before you toggled the network.

Why is the error card not showing when I disconnect the network?

Secret mission

Add 5-Day Forecast Vertical Slice

The true test of Clean Architecture is whether a second feature plugs in cleanly without modifying existing code. If your architecture is correct, adding a 5-day forecast should feel like filling in a template. Can you build a full vertical slice from entity to UI?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally and uses the OpenWeatherMap free tier, so there are no ongoing costs.

Resources you used:

  • OpenWeatherMap free account and API key (no recurring charges on free tier).
  • Local weather_engine project folder on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend the app further.

  • Your OpenWeatherMap API key remains active on the free tier indefinitely with no charges.
  • The free tier allows 60 calls per minute and 1,000,000 calls per month, so there is no risk of accidental billing.
  • Your local project files stay on your Desktop ready to open in Cursor whenever you want.

Pause - I'll come back to this later

Shut down running processes to free up system resources. Your project files and API key stay intact for when you return.

  • If the Flutter dev server is still running in your terminal, press Ctrl+C to stop it.
  • Your API key stays dormant until you run the app again. No calls means no usage.
  • Your weather_engine folder remains on your Desktop. Open it in Cursor anytime to resume.

Delete - I don't want to use this again

Remove all project resources so nothing lingers on your machine or account.

  • Delete the local project folder. On macOS or Linux, run:
cd ~/Desktop
rm -rf weather_engine
  • On Windows, run:
cd ~\Desktop
Remove-Item -Recurse -Force weather_engine

Nice Work!

Nice Work!

You just built a production-grade Flutter weather app with enterprise Clean Architecture, live API data, route guards, and structured error handling.

You've learned how to:

  • Built a feature-first Clean Architecture Flutter app with strict dependency rules between domain, data, and presentation layers.
  • Wired GoRouter with StatefulShellRoute for persistent bottom navigation and an onEnter guard that blocks unauthenticated routes.
  • Connected all layers via get_it dependency injection so data flows from the HTTP client through the Repository and UseCase into BLoC states that the UI renders.
  • Secret Mission: Added a 5-day forecast as a second vertical slice, proving the architecture scales without modifying existing features.

Ready to quiz yourself?