Crypto Portfolio Tracker in Flutter

Build a live crypto tracker app with Clean Architecture and BLoC.

Introduction

30 Second Summary

Crypto prices change every second. If you want to track your holdings without trusting a third-party app with your exchange credentials, you need your own tracker.

In this project, you will build a crypto portfolio tracker app using Flutter, the BLoC state management pattern, and Clean Architecture. This is the exact architecture combination that hiring managers test for in Flutter interviews.

What You'll Build

A mobile app that fetches live cryptocurrency prices from CoinGecko, lets you add coins to a personal watchlist, and handles loading and error states gracefully.

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

  • A home screen displaying top cryptocurrencies with live prices, 24h change percentages, and market cap data.
  • A Clean Architecture folder structure with proper data, domain, and presentation layer separation.
  • A BLoC state management system with predictable loading, success, and error states wired up with dependency injection using get_it.
  • Secret Mission: a real-time search feature with debounced input using BLoC event transformers.

Are there any prerequisites?

This project assumes intermediate Flutter knowledge. You should be comfortable with widgets, navigation, async/await, and basic state management.

You also need the Flutter SDK installed, an IDE with Flutter extensions (VS Code or Android Studio), and an emulator or physical device to run the app.

Set Up the Project and Dependencies

Every production Flutter app starts with a solid foundation. Before you write any feature code, you need a project scaffold with the right packages installed and a folder structure that enforces Clean Architecture boundaries.

In this step, you will create a new Flutter project, install the BLoC and dependency injection packages, and lay out the folder structure that separates your data, domain, and presentation layers.

In this step, get ready to:
  • Create a new Flutter project named crypto_tracker.
  • Add Clean Architecture and BLoC dependencies to your project.
  • Set up the folder structure for data, domain, and presentation layers.
Create a new Flutter project

Flutter's CLI scaffolds a complete starter app for you. You will create the project on your Desktop so it is easy to find.

  • Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or cmd (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the Flutter project by running this command:
flutter create crypto_tracker

You should see output ending with "All done!" and instructions to run the app.

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

Seeing 'flutter: command not found'?

Make sure Flutter is on your system PATH. Run flutter doctor to verify your installation. If that also fails, revisit the Flutter install guide for your platform.

help me fix 'flutter: command not found' on my system

Add project dependencies

Your project needs five packages to support BLoC state management, dependency injection, HTTP networking, and value equality.

  • Open the crypto_tracker project in your IDE. Press Cmd+Space (macOS) or the Windows key (Windows), type VS Code, and press Enter.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select the crypto_tracker folder on your Desktop, then click Open.
  • In the Explorer sidebar, click on pubspec.yaml to open it.
  • Find the dependencies: section and replace it with the following:
dependencies:
  flutter:
    sdk: flutter
  # BLoC state management
  flutter_bloc: ^8.1.3
  # Value equality for state comparison
  equatable: ^2.0.5
  # Dependency injection service locator
  get_it: ^7.6.4
  # HTTP client for API calls
  http: ^1.1.0
  # Event transformers for debouncing (used in Secret Mission)
  bloc_concurrency: ^0.2.4

What do these packages do?

  • flutter_bloc provides BlocProvider, BlocBuilder, and Cubit for managing app state.
  • equatable lets you compare objects by their properties instead of memory address. This is critical for BLoC state comparisons.
  • get_it is a service locator that wires up your dependencies without code generation.
  • http is Dart's standard HTTP client for making REST API calls.
  • bloc_concurrency adds event transformers like debouncing for advanced BLoC patterns.
  • Save pubspec.yaml.
  • Install the packages by running this command in your terminal:
flutter pub get

You should see output with green checkmarks and a message like "Got dependencies!" confirming all packages resolved.

Seeing version conflicts?

Make sure you replaced the entire dependencies: section (including the flutter: SDK line). Check that your indentation uses exactly two spaces per level. YAML is whitespace-sensitive.

help me fix dependency version conflicts in my Flutter pubspec.yaml

Create the Clean Architecture folder structure

Clean Architecture organizes code into three layers: data (API calls and models), domain (business logic and entities), and presentation (UI and state management). Creating these folders now enforces proper separation from the start.

  • Create all the layer folders by running this command from inside the crypto_tracker directory:

macOS

mkdir -p lib/core lib/data/datasources lib/data/models lib/data/repositories lib/domain/entities lib/domain/repositories lib/domain/usecases lib/presentation/bloc lib/presentation/pages lib/presentation/widgets

Windows

New-Item -ItemType Directory -Force -Path lib/core, lib/data/datasources, lib/data/models, lib/data/repositories, lib/domain/entities, lib/domain/repositories, lib/domain/usecases, lib/presentation/bloc, lib/presentation/pages, lib/presentation/widgets

What does this folder structure represent?

  • lib/data/ holds everything that talks to the outside world: API clients, JSON models, and repository implementations.
  • lib/domain/ holds pure business logic: entities, repository interfaces, and use cases. It has zero dependencies on Flutter or any package.
  • lib/presentation/ holds everything the user sees: BLoC classes, pages, and widgets.
  • lib/core/ holds shared utilities like the dependency injection container.
  • Confirm the folders exist by running:
ls lib/

You should see core, data, domain, presentation, and main.dart listed in the output.

Folders not showing up?

Make sure your terminal is inside the crypto_tracker folder (not the Desktop). Run pwd to check your current directory. It should end with crypto_tracker.

help me figure out why my mkdir command didn't create the folders

  • Verify the app runs on your emulator or device by running:
flutter run

You should see the default Flutter counter app launch on your device with a floating action button and a counter that increments when you tap it.

Use hot reload as you build

Keep flutter run active in your terminal throughout this project. Press r for hot reload or R for hot restart whenever you save changes.

✔️ Awesome, I've got everything!

Great. Double check you have saved pubspec.yaml and that your app is running.

ⓧ I'd like to double check the full code

Here is what the dependencies section of your pubspec.yaml should look like:

name: crypto_tracker

dependencies:
  flutter:
    sdk: flutter
  flutter_bloc: ^8.1.3
  equatable: ^2.0.5
  get_it: ^7.6.4
  http: ^1.1.0
  bloc_concurrency: ^0.2.4

Your project skeleton is locked in. Next up, you will build the data layer by creating the Coin model and connecting to the CoinGecko API.

Build the Domain Layer

Your project structure is ready and all your dependencies are installed. Now it's time to build the domain layer, the heart of your app's business logic.

In Clean Architecture, the domain layer defines what your app does without knowing how it does it. It contains your entities (data shapes), repository interfaces (contracts), and use cases (actions).

In this step, get ready to:
  • Define the Coin entity using Equatable for value comparison.
  • Create the abstract CoinRepository interface.
  • Build the GetCoinsMarketData use case that encapsulates business logic.
Define the Coin entity

Entities are the core business objects in your app. The Coin entity describes what a cryptocurrency looks like to your app, regardless of where the data comes from.

  • Open the file lib/domain/entities/coin.dart in your IDE.
  • Add the following code to define the Coin entity:
import 'package:equatable/equatable.dart';

class Coin extends Equatable {
  final String id;
  final String symbol;
  final String name;
  final String image;
  final double currentPrice;
  final double marketCap;
  final int marketCapRank;
  final double priceChangePercentage24h;
  final double high24h;
  final double low24h;

  const Coin({
    required this.id,
    required this.symbol,
    required this.name,
    required this.image,
    required this.currentPrice,
    required this.marketCap,
    required this.marketCapRank,
    required this.priceChangePercentage24h,
    required this.high24h,
    required this.low24h,
  });

  @override
  List<Object?> get props => [
        id,
        symbol,
        name,
        image,
        currentPrice,
        marketCap,
        marketCapRank,
        priceChangePercentage24h,
        high24h,
        low24h,
      ];
}

What does this code do?

The Coin class extends Equatable, which lets Dart compare two Coin objects by their field values instead of by memory reference.

This matters because BLoC uses equality checks to decide whether to rebuild the UI. Without Equatable, BLoC would treat two identical coin objects as different and trigger unnecessary rebuilds.

The props getter lists every field that should be compared when checking equality.

  • Save coin.dart.
  • Check that your IDE shows no red underlines or error indicators in the file.

Seeing red underlines?

  • If you see "Target of URI hasn't been generated" on the import line, make sure you ran flutter pub get in the previous step to install the equatable package.
  • Confirm your file is saved in the correct location: lib/domain/entities/coin.dart.

Why is my Coin entity showing import errors?

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

import 'package:equatable/equatable.dart';

class Coin extends Equatable {
  final String id;
  final String symbol;
  final String name;
  final String image;
  final double currentPrice;
  final double marketCap;
  final int marketCapRank;
  final double priceChangePercentage24h;
  final double high24h;
  final double low24h;

  const Coin({
    required this.id,
    required this.symbol,
    required this.name,
    required this.image,
    required this.currentPrice,
    required this.marketCap,
    required this.marketCapRank,
    required this.priceChangePercentage24h,
    required this.high24h,
    required this.low24h,
  });

  @override
  List<Object?> get props => [
        id,
        symbol,
        name,
        image,
        currentPrice,
        marketCap,
        marketCapRank,
        priceChangePercentage24h,
        high24h,
        low24h,
      ];
}
Create the CoinRepository interface

The domain layer needs a way to request data without knowing where it comes from. A repository interface defines a contract: "I need coin data, but I don't care if it comes from an API, a database, or a local file."

  • Open the file lib/domain/repositories/coin_repository.dart in your IDE.
  • Add the following code to define the repository contract:
import '../entities/coin.dart';

// Abstract class defines WHAT the repository does, not HOW
abstract class CoinRepository {
  // Returns a list of coins with market data
  Future<List<Coin>> getCoinsMarketData({
    String vsCurrency = 'usd',
    int perPage = 20,
    int page = 1,
  });
}

What does this code do?

The abstract keyword means this class cannot be instantiated directly. It only defines method signatures that concrete implementations must fulfill.

This is the Repository pattern in action. Later, you'll build a concrete class in the data layer that implements this interface and talks to the CoinGecko API.

Why define an interface the domain layer never implements?

This separation keeps your business logic testable. In tests, you can swap the real API implementation for a fake one that returns hardcoded data. Your use cases and BLoC never know the difference.

  • Save coin_repository.dart.
  • Check that your IDE shows no red underlines in this file.

Seeing an error on the import?

  • The import path ../entities/coin.dart is a relative path. It only works if your file is saved at exactly lib/domain/repositories/coin_repository.dart.
  • Make sure coin.dart from the previous substep is saved in lib/domain/entities/.

Help me fix my import path errors in my domain layer.

✔️ Awesome, I've got everything!

Great. Make sure the file is saved before continuing.

ⓧ I'd like to double check the full code

import '../entities/coin.dart';

abstract class CoinRepository {
  Future<List<Coin>> getCoinsMarketData({
    String vsCurrency = 'usd',
    int perPage = 20,
    int page = 1,
  });
}
Build the GetCoinsMarketData use case

A use case is a single action your app can perform. It sits between your BLoC and the repository, keeping business logic in one place.

  • Open the file lib/domain/usecases/get_coins_market_data.dart in your IDE.
  • Add the following code to define the use case:
import '../entities/coin.dart';
import '../repositories/coin_repository.dart';

// Encapsulates the action of fetching market data
class GetCoinsMarketData {
  // Depends on the abstract interface, not a concrete class
  final CoinRepository repository;

  GetCoinsMarketData({required this.repository});

  // The call() method makes this class callable like a function
  Future<List<Coin>> call({
    String vsCurrency = 'usd',
    int perPage = 20,
    int page = 1,
  }) {
    return repository.getCoinsMarketData(
      vsCurrency: vsCurrency,
      perPage: perPage,
      page: page,
    );
  }
}

What does this code do?

  • GetCoinsMarketData takes a CoinRepository as a dependency. This is Dependency Injection. The use case doesn't create the repository itself. It receives one from outside.
  • The call() method is a Dart convention that lets you invoke the class like a function. Instead of writing useCase.call(), you can just write useCase().
  • The use case delegates directly to the repository. Right now it looks simple, but this pattern pays off when you need to add caching, validation, or analytics around API calls.
  • Save get_coins_market_data.dart.
  • Check that your IDE shows no red underlines in this file.

Seeing errors on the repository import?

  • The import ../repositories/coin_repository.dart assumes this file lives in lib/domain/usecases/. Verify you created the file in the correct folder.
  • If you see an error on CoinRepository, go back and confirm that coin_repository.dart is saved properly in the previous substep.

Help me fix import errors in my use case file.

✔️ Awesome, I've got everything!

Great. Double check that all three files are saved.

ⓧ I'd like to double check the full code

import '../entities/coin.dart';
import '../repositories/coin_repository.dart';

class GetCoinsMarketData {
  final CoinRepository repository;

  GetCoinsMarketData({required this.repository});

  Future<List<Coin>> call({
    String vsCurrency = 'usd',
    int perPage = 20,
    int page = 1,
  }) {
    return repository.getCoinsMarketData(
      vsCurrency: vsCurrency,
      perPage: perPage,
      page: page,
    );
  }
}

Your domain layer is complete. You have a Coin entity, a repository contract, and a use case that ties them together. Next up, you'll build the data layer that connects to the CoinGecko API and fulfills that repository contract with real market data.

Build the Data Layer

Your domain layer defines what data your app needs and what operations it supports. Now you need to build the layer that actually fetches that data from the real world.

The data layer is where your app talks to external services. In Clean Architecture, this layer contains three pieces: a model that knows how to parse JSON, a data source that calls the API, and a repository implementation that connects everything to your domain layer.

In this step, get ready to:
  • Create a CoinModel class that parses CoinGecko JSON into your Coin entity.
  • Build a remote data source that fetches live market data from the CoinGecko API.
  • Create a repository implementation that wraps the data source with error handling.
Create the CoinModel with JSON parsing

Your Coin entity in the domain layer is a plain Dart class with no idea how JSON works. The data layer needs a model class that extends the entity and adds the ability to parse API responses.

  • Create a new file called coin_model.dart inside your lib/data/models/ folder.
  • Add the following code to define the CoinModel class:
import '../../domain/entities/coin.dart';

class CoinModel extends Coin {
  const CoinModel({
    required super.id,
    required super.symbol,
    required super.name,
    required super.image,
    required super.currentPrice,
    required super.marketCap,
    required super.marketCapRank,
    required super.priceChangePercentage24h,
    required super.high24h,
    required super.low24h,
  });

  factory CoinModel.fromJson(Map<String, dynamic> json) {
    return CoinModel(
      id: json['id'] as String,
      symbol: json['symbol'] as String,
      name: json['name'] as String,
      image: json['image'] as String,
      currentPrice: (json['current_price'] as num?)?.toDouble() ?? 0.0,
      marketCap: (json['market_cap'] as num?)?.toDouble() ?? 0.0,
      marketCapRank: (json['market_cap_rank'] as num?)?.toInt() ?? 0,
      priceChangePercentage24h:
          (json['price_change_percentage_24h'] as num?)?.toDouble() ?? 0.0,
      high24h: (json['high_24h'] as num?)?.toDouble() ?? 0.0,
      low24h: (json['low_24h'] as num?)?.toDouble() ?? 0.0,
    );
  }
}

What does this code do?

CoinModel extends your Coin entity, so anywhere your app expects a Coin, a CoinModel works too. The fromJson factory constructor handles the messy work of converting API field names (like current_price) into clean Dart field names (like currentPrice).

The ?? 0.0 fallbacks handle cases where the API returns null for a numeric field. This prevents your app from crashing on unexpected API responses.

  • Save coin_model.dart.
  • Confirm there are no red underlines or errors in your IDE. The import path to coin.dart should resolve cleanly because the domain layer already exists.

Seeing an import error?

Check that the relative path ../../domain/entities/coin.dart matches your folder structure. The file must live at lib/data/models/coin_model.dart (two levels deep inside lib/) for the relative path to resolve correctly.

Help me fix my import path in coin_model.dart

Build the remote data source

The data source is the class that actually calls the CoinGecko API. It knows about HTTP requests, headers, and JSON decoding. Nothing else in your app touches the network directly.

  • Create a new file called coin_remote_data_source.dart inside your lib/data/datasources/ folder.
  • Add the abstract interface and its implementation by pasting this code:
import 'dart:convert';

import 'package:http/http.dart' as http;

import '../models/coin_model.dart';

abstract class CoinRemoteDataSource {
  Future<List<CoinModel>> getCoinsMarketData({
    String vsCurrency = 'usd',
    int perPage = 20,
    int page = 1,
  });
}

class CoinRemoteDataSourceImpl implements CoinRemoteDataSource {
  final http.Client client;

  static const String _baseUrl = 'https://api.coingecko.com/api/v3';
  static const String _apiKey = 'your-coingecko-demo-api-key-here';

  CoinRemoteDataSourceImpl({required this.client});

  @override
  Future<List<CoinModel>> getCoinsMarketData({
    String vsCurrency = 'usd',
    int perPage = 20,
    int page = 1,
  }) async {
    final uri = Uri.parse(
      '$_baseUrl/coins/markets?vs_currency=$vsCurrency&order=market_cap_desc&per_page=$perPage&page=$page&sparkline=false',
    );

    final response = await client.get(
      uri,
      headers: {'x-cg-demo-api-key': _apiKey},
    );

    if (response.statusCode == 200) {
      final List<dynamic> jsonList = json.decode(response.body);
      return jsonList.map((json) => CoinModel.fromJson(json)).toList();
    } else {
      throw Exception('Failed to fetch coins: ${response.statusCode}');
    }
  }
}

What does this code do?

  • CoinRemoteDataSource is an abstract class (interface) so you can swap in a fake data source for testing later.
  • CoinRemoteDataSourceImpl takes an http.Client through its constructor instead of creating one internally. This is dependency injection at work.
  • The /coins/markets endpoint returns the top coins sorted by market cap, with price and 24-hour change data included.
  • The x-cg-demo-api-key header authenticates your request with CoinGecko's Demo API.
  • Replace your-coingecko-demo-api-key-here in the _apiKey field with the Demo API key you obtained from CoinGecko in Step 1.
  • Save coin_remote_data_source.dart.
  • Confirm there are no red underlines. The http import should resolve because you added the http package in Step 1.

http package not found?

If you see an error on the package:http/http.dart import, the package might not be installed. Run flutter pub get in your terminal to fetch dependencies. If it still fails, check your pubspec.yaml contains http: ^1.6.0 under dependencies.

Help me fix the http import in my data source

Create the repository implementation

The last piece of the data layer is the repository implementation. This class fulfills the contract defined by your abstract CoinRepository in the domain layer. It wraps the data source call with error handling so the rest of your app never deals with raw exceptions from the network.

  • Create a new file called coin_repository_impl.dart inside your lib/data/repositories/ folder.
  • Add the repository implementation by pasting this code:
import '../../domain/entities/coin.dart';
import '../../domain/repositories/coin_repository.dart';
import '../datasources/coin_remote_data_source.dart';

class CoinRepositoryImpl implements CoinRepository {
  final CoinRemoteDataSource remoteDataSource;

  CoinRepositoryImpl({required this.remoteDataSource});

  @override
  Future<List<Coin>> getCoinsMarketData({
    String vsCurrency = 'usd',
    int perPage = 20,
    int page = 1,
  }) async {
    try {
      final coins = await remoteDataSource.getCoinsMarketData(
        vsCurrency: vsCurrency,
        perPage: perPage,
        page: page,
      );
      return coins;
    } catch (e) {
      throw Exception('Failed to get coins market data: $e');
    }
  }
}

What does this code do?

  • CoinRepositoryImpl implements the abstract CoinRepository interface from your domain layer. This is the Repository pattern in action.
  • The try/catch block catches any exceptions from the network call and wraps them in a more descriptive error message.
  • The return type is List<Coin> (the entity), not List<CoinModel>. This works because CoinModel extends Coin. The domain layer never knows about the model class.
  • Save coin_repository_impl.dart.
  • Confirm there are no errors. All three imports should resolve cleanly against the files you created earlier.

Seeing a 'not a valid override' error?

This means the method signature in CoinRepositoryImpl does not exactly match the abstract class in coin_repository.dart. Double check the parameter names, types, and default values match exactly between both files.

Help me fix the override mismatch in my repository

Why separate the data source from the repository?

Right now it might feel like overkill to have both a data source and a repository that just calls it. The separation pays off when your app grows. If you later add a local cache or a second API, the repository decides which data source to use. The rest of your app never changes.

✔️ Awesome, I've got everything!

Great. Make sure all three data layer files are saved before moving on.

ⓧ I'd like to double check the full code

Here are all three data layer files in full for comparison.

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

class CoinModel extends Coin {
  const CoinModel({
    required super.id,
    required super.symbol,
    required super.name,
    required super.image,
    required super.currentPrice,
    required super.marketCap,
    required super.marketCapRank,
    required super.priceChangePercentage24h,
    required super.high24h,
    required super.low24h,
  });

  factory CoinModel.fromJson(Map<String, dynamic> json) {
    return CoinModel(
      id: json['id'] as String,
      symbol: json['symbol'] as String,
      name: json['name'] as String,
      image: json['image'] as String,
      currentPrice: (json['current_price'] as num?)?.toDouble() ?? 0.0,
      marketCap: (json['market_cap'] as num?)?.toDouble() ?? 0.0,
      marketCapRank: (json['market_cap_rank'] as num?)?.toInt() ?? 0,
      priceChangePercentage24h:
          (json['price_change_percentage_24h'] as num?)?.toDouble() ?? 0.0,
      high24h: (json['high_24h'] as num?)?.toDouble() ?? 0.0,
      low24h: (json['low_24h'] as num?)?.toDouble() ?? 0.0,
    );
  }
}
import 'dart:convert';

import 'package:http/http.dart' as http;

import '../models/coin_model.dart';

abstract class CoinRemoteDataSource {
  Future<List<CoinModel>> getCoinsMarketData({
    String vsCurrency = 'usd',
    int perPage = 20,
    int page = 1,
  });
}

class CoinRemoteDataSourceImpl implements CoinRemoteDataSource {
  final http.Client client;

  static const String _baseUrl = 'https://api.coingecko.com/api/v3';
  static const String _apiKey = 'your-coingecko-demo-api-key-here';

  CoinRemoteDataSourceImpl({required this.client});

  @override
  Future<List<CoinModel>> getCoinsMarketData({
    String vsCurrency = 'usd',
    int perPage = 20,
    int page = 1,
  }) async {
    final uri = Uri.parse(
      '$_baseUrl/coins/markets?vs_currency=$vsCurrency&order=market_cap_desc&per_page=$perPage&page=$page&sparkline=false',
    );

    final response = await client.get(
      uri,
      headers: {'x-cg-demo-api-key': _apiKey},
    );

    if (response.statusCode == 200) {
      final List<dynamic> jsonList = json.decode(response.body);
      return jsonList.map((json) => CoinModel.fromJson(json)).toList();
    } else {
      throw Exception('Failed to fetch coins: ${response.statusCode}');
    }
  }
}
import '../../domain/entities/coin.dart';
import '../../domain/repositories/coin_repository.dart';
import '../datasources/coin_remote_data_source.dart';

class CoinRepositoryImpl implements CoinRepository {
  final CoinRemoteDataSource remoteDataSource;

  CoinRepositoryImpl({required this.remoteDataSource});

  @override
  Future<List<Coin>> getCoinsMarketData({
    String vsCurrency = 'usd',
    int perPage = 20,
    int page = 1,
  }) async {
    try {
      final coins = await remoteDataSource.getCoinsMarketData(
        vsCurrency: vsCurrency,
        perPage: perPage,
        page: page,
      );
      return coins;
    } catch (e) {
      throw Exception('Failed to get coins market data: $e');
    }
  }
}

Your data layer is complete. You now have a model that parses JSON, a data source that talks to CoinGecko, and a repository that shields the rest of your app from network details. Next up, you will wire all these layers together with dependency injection so your BLoC can start using them.

Build the Presentation Layer with BLoC

Your data and domain layers can fetch coin data and define the business rules. Now you need a way to manage the UI state of your app.

The BLoC pattern (Business Logic Component) separates your UI from your business logic by using events and states. The UI sends events ("fetch coins" or "refresh"), and the BLoC responds by emitting new states (loading, loaded, or error) that the UI reacts to.

In this step, get ready to:
  • Define the events and states for your coin list BLoC.
  • Build the CoinListBloc that fetches market data and manages UI state.
  • Create a WatchlistCubit for local watchlist management.
Define the coin list events and states

In BLoC, every user interaction maps to an event, and every possible screen state is an explicit class. This means you define upfront exactly what can happen and what the UI can look like.

Why BLoC instead of Riverpod?

BLoC forces you to declare every event and state as a class. This makes the data flow explicit and easy to trace during debugging.

Riverpod is more concise, but BLoC's structured approach is the standard in enterprise Flutter codebases. It is also the pattern most commonly tested in Flutter job interviews.

  • Create the file lib/presentation/bloc/coin_list_bloc.dart and add the event classes by pasting this code:
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import '../../domain/entities/coin.dart';
import '../../domain/usecases/get_coins_market_data.dart';

// Events
abstract class CoinListEvent extends Equatable {
  const CoinListEvent();

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

class FetchCoins extends CoinListEvent {}

class RefreshCoins extends CoinListEvent {}

What does this code do?

  • CoinListEvent is the abstract base class for all events. Extending Equatable lets BLoC compare events efficiently to avoid duplicate processing.
  • FetchCoins triggers the initial data load when the screen opens.
  • RefreshCoins triggers a data reload when the user pulls to refresh.

You won't see a change yet. The next chunk defines the states these events will trigger.

Seeing import errors?

  • Make sure you created the file in the correct path. The folder structure should be lib/presentation/bloc/.
  • Confirm your domain layer files exist at the import paths shown. If you see red squiggles on the imports, double check that coin.dart and get_coins_market_data.dart are in place.

Help me fix BLoC import errors in my Flutter project.

  • Below the RefreshCoins class in the same file, add the state classes:
// States
abstract class CoinListState extends Equatable {
  const CoinListState();

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

class CoinListInitial extends CoinListState {}

class CoinListLoading extends CoinListState {}

class CoinListLoaded extends CoinListState {
  final List<Coin> coins;

  const CoinListLoaded({required this.coins});

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

class CoinListError extends CoinListState {
  final String message;

  const CoinListError({required this.message});

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

What does this code do?

  • CoinListInitial is the starting state before anything happens.
  • CoinListLoading tells the UI to show a loading spinner while data is being fetched.
  • CoinListLoaded carries the list of coins to display. Its props override lets Equatable detect when the coin list changes.
  • CoinListError carries an error message so the UI can show what went wrong.

You won't see a change yet. The next chunk builds the BLoC class that connects these events and states together.

Red underline on List<Coin>?

  • This means the Coin entity import is not resolving. Check that the import line at the top says import '../../domain/entities/coin.dart' with the correct relative path.

Help me fix Coin import in my BLoC file.

Build the CoinListBloc class

The BLoC class is the conductor. It receives events, runs the appropriate logic, and emits new states for the UI to render.

  • Below the CoinListError class in the same file, add the BLoC class:
// BLoC
class CoinListBloc extends Bloc<CoinListEvent, CoinListState> {
  final GetCoinsMarketData getCoinsMarketData;

  CoinListBloc({required this.getCoinsMarketData}) : super(CoinListInitial()) {
    on<FetchCoins>(_onFetchCoins);
    on<RefreshCoins>(_onRefreshCoins);
  }

  Future<void> _onFetchCoins(
    FetchCoins event,
    Emitter<CoinListState> emit,
  ) async {
    emit(CoinListLoading());
    try {
      final coins = await getCoinsMarketData();
      emit(CoinListLoaded(coins: coins));
    } catch (e) {
      emit(CoinListError(message: e.toString()));
    }
  }

  Future<void> _onRefreshCoins(
    RefreshCoins event,
    Emitter<CoinListState> emit,
  ) async {
    try {
      final coins = await getCoinsMarketData();
      emit(CoinListLoaded(coins: coins));
    } catch (e) {
      emit(CoinListError(message: e.toString()));
    }
  }
}

What does this code do?

  • The constructor takes your GetCoinsMarketData use case as a dependency. This is dependency injection in action.
  • on<FetchCoins> and on<RefreshCoins> register handlers that run when those events are dispatched.
  • _onFetchCoins emits CoinListLoading first (so the UI shows a spinner), then fetches data and emits either CoinListLoaded or CoinListError.
  • _onRefreshCoins skips the loading state so the existing list stays visible while fresh data loads in the background.
  • Save coin_list_bloc.dart.
  • Run the analyzer to confirm there are no errors by running this command:
flutter analyze

You should see "No issues found!" in the terminal output.

Seeing analysis issues?

  • If you see "unused import" warnings, that is OK for now. The imports for Coin and GetCoinsMarketData are used in the BLoC class below the states.
  • If you see type errors, make sure your CoinListLoaded class has the field typed as List<Coin> (not List<CoinModel>).
  • If you see "Target of URI doesn't exist", double check that your domain layer files are saved at the exact paths referenced in the import statements.

Help me fix flutter analyze errors in my BLoC file.

✔️ Awesome, I've got everything!

Great. Make sure you have saved coin_list_bloc.dart before moving on.

ⓧ I'd like to double check the full code

import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import '../../domain/entities/coin.dart';
import '../../domain/usecases/get_coins_market_data.dart';

// Events
abstract class CoinListEvent extends Equatable {
  const CoinListEvent();

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

class FetchCoins extends CoinListEvent {}

class RefreshCoins extends CoinListEvent {}

// States
abstract class CoinListState extends Equatable {
  const CoinListState();

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

class CoinListInitial extends CoinListState {}

class CoinListLoading extends CoinListState {}

class CoinListLoaded extends CoinListState {
  final List<Coin> coins;

  const CoinListLoaded({required this.coins});

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

class CoinListError extends CoinListState {
  final String message;

  const CoinListError({required this.message});

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

// BLoC
class CoinListBloc extends Bloc<CoinListEvent, CoinListState> {
  final GetCoinsMarketData getCoinsMarketData;

  CoinListBloc({required this.getCoinsMarketData}) : super(CoinListInitial()) {
    on<FetchCoins>(_onFetchCoins);
    on<RefreshCoins>(_onRefreshCoins);
  }

  Future<void> _onFetchCoins(
    FetchCoins event,
    Emitter<CoinListState> emit,
  ) async {
    emit(CoinListLoading());
    try {
      final coins = await getCoinsMarketData();
      emit(CoinListLoaded(coins: coins));
    } catch (e) {
      emit(CoinListError(message: e.toString()));
    }
  }

  Future<void> _onRefreshCoins(
    RefreshCoins event,
    Emitter<CoinListState> emit,
  ) async {
    try {
      final coins = await getCoinsMarketData();
      emit(CoinListLoaded(coins: coins));
    } catch (e) {
      emit(CoinListError(message: e.toString()));
    }
  }
}
Create the WatchlistCubit

Your coin list needs a full BLoC because it handles async API calls with loading and error states. The watchlist is simpler. It just stores a list of coin IDs locally.

What is a Cubit?

A Cubit is a simplified version of BLoC. Instead of defining event classes and registering handlers, you call methods directly on the Cubit and it emits new states.

Use Cubit when state changes are straightforward (like toggling items in a list). Use a full BLoC when you need event-driven logic with async operations.

  • Create the file lib/presentation/bloc/watchlist_cubit.dart and paste this code:
import 'package:flutter_bloc/flutter_bloc.dart';

class WatchlistCubit extends Cubit<List<String>> {
  WatchlistCubit() : super([]);

  // Adds or removes a coin ID from the watchlist
  void toggleCoin(String coinId) {
    final currentList = List<String>.from(state);
    if (currentList.contains(coinId)) {
      currentList.remove(coinId);
    } else {
      currentList.add(coinId);
    }
    emit(currentList);
  }

  // Checks if a coin is currently in the watchlist
  bool isWatched(String coinId) {
    return state.contains(coinId);
  }
}

What does this code do?

  • WatchlistCubit extends Cubit<List<String>>, meaning its state is a list of coin ID strings. It starts empty.
  • toggleCoin creates a copy of the current list, adds or removes the coin, then emits the new list. Copying is essential because BLoC only detects changes when a new object is emitted.
  • isWatched is a helper that UI widgets can call to check if a coin's star icon should be filled.
  • Save watchlist_cubit.dart.
  • Run the analyzer again to confirm everything is clean:
flutter analyze

You should see "No issues found!" confirming both your BLoC and Cubit files are valid.

Seeing errors in watchlist_cubit.dart?

  • If you see "Undefined class Cubit", check that your import says import 'package:flutter_bloc/flutter_bloc.dart' with the exact package name.
  • If the file itself has no errors but you see warnings elsewhere, focus on the watchlist_cubit.dart output only. Other files may show warnings until we wire everything together in the next step.

Help me debug my WatchlistCubit.

Your presentation layer logic is ready. You have a BLoC that can fetch coins and emit loading, success, or error states, plus a Cubit that manages a local watchlist. Next up, you will wire all these pieces together with dependency injection so the app can actually run.

Wire Up Dependency Injection and main.dart

You have three complete layers: data, domain, and presentation. Each one knows its own job, but none of them know about each other yet.

Dependency injection solves this by creating a single place that builds every object and hands it the collaborators it needs. You will use get_it as a service locator to register all your classes, then wire them into main.dart so Flutter can start serving live crypto data the moment the app launches.

In this step, get ready to:
  • Create an injection container that registers all dependencies (data source, repository, use case, BLoC).
  • Initialize the service locator in main.dart and provide BLoCs to the widget tree.
  • Run the app and confirm it compiles with all layers connected.
Create the injection container

The injection container is a single file that knows how to build every class in your app. When your BLoC needs a use case, it asks the container instead of constructing the use case itself.

  • Create the file lib/core/injection_container.dart and add the following code:
import 'package:get_it/get_it.dart';
import 'package:http/http.dart' as http;

import '../data/datasources/coin_remote_data_source.dart';
import '../data/repositories/coin_repository_impl.dart';
import '../domain/repositories/coin_repository.dart';
import '../domain/usecases/get_coins_market_data.dart';
import '../presentation/bloc/coin_list_bloc.dart';
import '../presentation/bloc/watchlist_cubit.dart';

final sl = GetIt.instance;

void init() {
  // BLoC
  sl.registerFactory(() => CoinListBloc(getCoinsMarketData: sl()));
  sl.registerFactory(() => WatchlistCubit());

  // Use Cases
  sl.registerLazySingleton(() => GetCoinsMarketData(repository: sl()));

  // Repository
  sl.registerLazySingleton<CoinRepository>(
    () => CoinRepositoryImpl(remoteDataSource: sl()),
  );

  // Data Sources
  sl.registerLazySingleton<CoinRemoteDataSource>(
    () => CoinRemoteDataSourceImpl(client: sl()),
  );

  // External
  sl.registerLazySingleton(() => http.Client());
}

What does this code do?

The sl variable is your service locator. Think of it as a phone book that maps class types to their constructors.

  • registerFactory creates a fresh instance every time it is requested. BLoCs use this because each screen needs its own state.
  • registerLazySingleton creates the instance once and reuses it. The repository and data source are shared across the app.
  • When you write sl() inside a registration, get_it resolves the dependency automatically by type.

Why register from top to bottom?

The registration order does not matter at registration time. get_it resolves dependencies lazily when they are first requested at runtime. The top-to-bottom grouping (BLoC, Use Cases, Repository, Data Sources, External) is a readability convention that mirrors the Clean Architecture layers.

  • Save injection_container.dart.

Seeing import errors?

Make sure the file is at lib/core/injection_container.dart. The imports use relative paths that depend on this exact location.

Confirm that your lib/data/, lib/domain/, and lib/presentation/ folders contain all the files from previous steps.

help me fix import errors in my injection container

Set up main.dart with DI and BlocProvider

The injection container registers everything, but nothing calls it yet. You need to initialize the container before the app starts, then provide the BLoCs to the widget tree so any widget can access them.

  • Before creating main.dart, create a placeholder page so the app can compile. Create the file lib/presentation/pages/coin_list_page.dart with this temporary code:
import 'package:flutter/material.dart';

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

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(child: Text('Crypto Tracker')),
    );
  }
}

Why a placeholder?

main.dart imports CoinListPage as its home screen. You will build the real UI in the next step, but the app needs this file to exist so it can compile right now.

  • Now open lib/main.dart and replace its entire contents with the following:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import 'core/injection_container.dart' as di;
import 'presentation/bloc/coin_list_bloc.dart';
import 'presentation/bloc/watchlist_cubit.dart';
import 'presentation/pages/coin_list_page.dart';

void main() {
  // Initialize all dependencies before running the app
  di.init();
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MultiBlocProvider(
      // Provide BLoCs to the entire widget tree
      providers: [
        BlocProvider(
          create: (_) => di.sl<CoinListBloc>()..add(FetchCoins()),
        ),
        BlocProvider(
          create: (_) => di.sl<WatchlistCubit>(),
        ),
      ],
      child: MaterialApp(
        title: 'Crypto Portfolio Tracker',
        theme: ThemeData(
          colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
          useMaterial3: true,
        ),
        home: const CoinListPage(),
      ),
    );
  }
}

What does this code do?

  • di.init() runs your injection container, registering every class before the app starts.
  • MultiBlocProvider makes both BLoCs available to every widget in the tree without passing them manually through constructors.
  • The ..add(FetchCoins()) cascade operator dispatches the first event immediately when the BLoC is created. This triggers the API call as soon as the app launches.
  • di.sl<CoinListBloc>() asks the service locator to build a CoinListBloc, which automatically resolves the GetCoinsMarketData use case and its entire dependency chain.
  • Save main.dart.
  • Run the app by executing this command in your terminal:
flutter run

You should see the app launch on your emulator or device showing a white screen with the text "Crypto Tracker" in the center. The BLoC is silently fetching coin data from CoinGecko in the background.

App crashing on launch?

If you see a Bad state: GetIt: Object/factory not registered error, check that your injection container registers types in a way that get_it can resolve them. Every sl() call inside a registration must have a matching registered type.

If you see a MissingPluginException or network error, confirm your device or emulator has internet access. The BLoC tries to fetch data immediately on launch.

help me debug my dependency injection setup

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the complete injection container:

import 'package:get_it/get_it.dart';
import 'package:http/http.dart' as http;

import '../data/datasources/coin_remote_data_source.dart';
import '../data/repositories/coin_repository_impl.dart';
import '../domain/repositories/coin_repository.dart';
import '../domain/usecases/get_coins_market_data.dart';
import '../presentation/bloc/coin_list_bloc.dart';
import '../presentation/bloc/watchlist_cubit.dart';

final sl = GetIt.instance;

void init() {
  // BLoC
  sl.registerFactory(() => CoinListBloc(getCoinsMarketData: sl()));
  sl.registerFactory(() => WatchlistCubit());

  // Use Cases
  sl.registerLazySingleton(() => GetCoinsMarketData(repository: sl()));

  // Repository
  sl.registerLazySingleton<CoinRepository>(
    () => CoinRepositoryImpl(remoteDataSource: sl()),
  );

  // Data Sources
  sl.registerLazySingleton<CoinRemoteDataSource>(
    () => CoinRemoteDataSourceImpl(client: sl()),
  );

  // External
  sl.registerLazySingleton(() => http.Client());
}

And here is the complete main.dart:

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import 'core/injection_container.dart' as di;
import 'presentation/bloc/coin_list_bloc.dart';
import 'presentation/bloc/watchlist_cubit.dart';
import 'presentation/pages/coin_list_page.dart';

void main() {
  di.init();
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MultiBlocProvider(
      providers: [
        BlocProvider(
          create: (_) => di.sl<CoinListBloc>()..add(FetchCoins()),
        ),
        BlocProvider(
          create: (_) => di.sl<WatchlistCubit>(),
        ),
      ],
      child: MaterialApp(
        title: 'Crypto Portfolio Tracker',
        theme: ThemeData(
          colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
          useMaterial3: true,
        ),
        home: const CoinListPage(),
      ),
    );
  }
}

Your entire dependency graph is connected. The app launches, initializes every dependency, and the BLoC fires off an API request before any widget even renders. Next up, you will build the actual UI that reacts to loading, loaded, and error states from your BLoC.

Build the UI with BlocBuilder

You have a complete data pipeline from the CoinGecko API through your domain layer and into BLoC state management.

The missing piece is the actual UI. Your main.dart already references CoinListPage, but that widget does not exist yet. In this step, you will build three widgets that react to your BLoC states and render live crypto data on screen.

In this step, get ready to:
  • Build a CoinTile widget with price data and a watchlist star icon.
  • Create the CoinListPage that uses BlocBuilder to render loading, loaded, and error states with pull-to-refresh.
  • Build a WatchlistPage that filters and displays only starred coins.
Build the CoinTile widget

Every coin in the list needs a reusable tile that shows its image, name, price, and 24-hour change. This widget also includes a star icon that toggles the coin on and off your watchlist.

  • Create the file lib/presentation/widgets/coin_tile.dart and paste the following code:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import '../../domain/entities/coin.dart';
import '../bloc/watchlist_cubit.dart';

class CoinTile extends StatelessWidget {
  final Coin coin;

  const CoinTile({super.key, required this.coin});

  @override
  Widget build(BuildContext context) {
    // Determine if the 24h change is positive for color coding
    final isPositive = coin.priceChangePercentage24h >= 0;

    return ListTile(
      // Coin logo loaded from network URL
      leading: CircleAvatar(
        backgroundImage: NetworkImage(coin.image),
        backgroundColor: Colors.grey[200],
      ),
      // Coin name in bold
      title: Text(
        coin.name,
        style: const TextStyle(fontWeight: FontWeight.bold),
      ),
      // Symbol in uppercase, grey text
      subtitle: Text(
        coin.symbol.toUpperCase(),
        style: TextStyle(color: Colors.grey[600]),
      ),
      trailing: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          // Price and percentage column
          Column(
            mainAxisAlignment: MainAxisAlignment.center,
            crossAxisAlignment: CrossAxisAlignment.end,
            children: [
              Text(
                '\$${coin.currentPrice.toStringAsFixed(2)}',
                style: const TextStyle(fontWeight: FontWeight.bold),
              ),
              Text(
                '${isPositive ? '+' : ''}${coin.priceChangePercentage24h.toStringAsFixed(2)}%',
                style: TextStyle(
                  color: isPositive ? Colors.green : Colors.red,
                  fontSize: 12,
                ),
              ),
            ],
          ),
          const SizedBox(width: 8),
          // Watchlist star toggle using WatchlistCubit
          BlocBuilder<WatchlistCubit, List<String>>(
            builder: (context, watchlist) {
              final isWatched = watchlist.contains(coin.id);
              return IconButton(
                icon: Icon(
                  isWatched ? Icons.star : Icons.star_border,
                  color: isWatched ? Colors.amber : Colors.grey,
                ),
                onPressed: () {
                  context.read<WatchlistCubit>().toggleCoin(coin.id);
                },
              );
            },
          ),
        ],
      ),
    );
  }
}

What does this code do?

CoinTile is a reusable widget that accepts a single Coin entity and renders it as a Material ListTile.

  • The leading CircleAvatar loads the coin's logo image from the network.
  • The trailing Row shows price formatted to two decimal places and the 24-hour percentage change in green (positive) or red (negative).
  • A nested BlocBuilder listens to WatchlistCubit and renders a filled amber star or an empty grey star depending on whether the coin is watched.
  • Save coin_tile.dart.

You will not see any visual change yet because no page renders CoinTile. The next chunk creates that page.

Seeing import errors?

  • Confirm the file path is exactly lib/presentation/widgets/coin_tile.dart. The widgets folder must exist inside presentation.
  • Make sure watchlist_cubit.dart exists at lib/presentation/bloc/watchlist_cubit.dart from the previous step.
  • help me fix import errors in coin_tile.dart
Create the CoinListPage with BlocBuilder

The CoinListPage is the home screen of your app. It uses BlocBuilder to check the current state of CoinListBloc and renders a completely different widget tree for each state: a spinner for loading, a scrollable list for loaded, and an error message with a retry button for errors.

  • Create the file lib/presentation/pages/coin_list_page.dart and paste the following code:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import '../../domain/entities/coin.dart';
import '../bloc/coin_list_bloc.dart';
import '../bloc/watchlist_cubit.dart';
import '../widgets/coin_tile.dart';
import 'watchlist_page.dart';

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Crypto Tracker'),
        // Star icon navigates to the watchlist page
        actions: [
          IconButton(
            icon: const Icon(Icons.star),
            onPressed: () {
              Navigator.push(
                context,
                MaterialPageRoute(
                  builder: (_) => const WatchlistPage(),
                ),
              );
            },
          ),
        ],
      ),
      // BlocBuilder reacts to CoinListBloc state changes
      body: BlocBuilder<CoinListBloc, CoinListState>(
        builder: (context, state) {
          if (state is CoinListLoading) {
            return const Center(child: CircularProgressIndicator());
          } else if (state is CoinListLoaded) {
            // Pull-to-refresh dispatches RefreshCoins event
            return RefreshIndicator(
              onRefresh: () async {
                context.read<CoinListBloc>().add(RefreshCoins());
              },
              child: ListView.builder(
                itemCount: state.coins.length,
                itemBuilder: (context, index) {
                  final coin = state.coins[index];
                  return CoinTile(coin: coin);
                },
              ),
            );
          } else if (state is CoinListError) {
            // Error state shows message and retry button
            return Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Text(
                    'Something went wrong',
                    style: Theme.of(context).textTheme.titleMedium,
                  ),
                  const SizedBox(height: 8),
                  Text(state.message),
                  const SizedBox(height: 16),
                  ElevatedButton(
                    onPressed: () {
                      context.read<CoinListBloc>().add(FetchCoins());
                    },
                    child: const Text('Retry'),
                  ),
                ],
              ),
            );
          }
          return const SizedBox.shrink();
        },
      ),
    );
  }
}

What does this code do?

This is where the BLoC pattern pays off. A single BlocBuilder widget rebuilds whenever CoinListBloc emits a new state.

  • CoinListLoading shows a centered spinner.
  • CoinListLoaded wraps a ListView.builder inside a RefreshIndicator. Pulling down dispatches RefreshCoins to re-fetch data without showing the full loading spinner.
  • CoinListError displays the error message and a Retry button that dispatches FetchCoins again.
  • Save coin_list_page.dart.

Your app still will not compile because coin_list_page.dart imports watchlist_page.dart which does not exist yet. The next substep fixes that.

Seeing a missing import for WatchlistPage?

This is expected. The import resolves once you create watchlist_page.dart in the next substep.

If you see other unresolved imports (like coin_list_bloc.dart or coin_tile.dart), double-check your file paths match the structure from previous steps.

Build the WatchlistPage

The WatchlistPage filters the full coin list down to only the coins the user has starred. It reads both the WatchlistCubit (for saved IDs) and the CoinListBloc (for the full coin data) to produce a filtered list.

  • Create the file lib/presentation/pages/watchlist_page.dart and paste the following code:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import '../bloc/coin_list_bloc.dart';
import '../bloc/watchlist_cubit.dart';
import '../widgets/coin_tile.dart';

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Watchlist'),
      ),
      // Rebuilds whenever the watchlist changes
      body: BlocBuilder<WatchlistCubit, List<String>>(
        builder: (context, watchlist) {
          // Read the current coin list from CoinListBloc
          final coinListState = context.watch<CoinListBloc>().state;

          if (coinListState is CoinListLoaded) {
            // Filter to only coins whose IDs are in the watchlist
            final watchedCoins = coinListState.coins
                .where((coin) => watchlist.contains(coin.id))
                .toList();

            if (watchedCoins.isEmpty) {
              return const Center(
                child: Text('No coins in your watchlist yet.\nTap the star icon to add coins.'),
              );
            }

            return ListView.builder(
              itemCount: watchedCoins.length,
              itemBuilder: (context, index) {
                return CoinTile(coin: watchedCoins[index]);
              },
            );
          }

          return const Center(child: CircularProgressIndicator());
        },
      ),
    );
  }
}

What does this code do?

  • BlocBuilder<WatchlistCubit, List<String>> rebuilds the page whenever the watchlist changes (a coin is added or removed).
  • context.watch<CoinListBloc>().state reads the already-loaded coin data so the page can filter by watchlist IDs without making a second API call.
  • When the watchlist is empty, a helpful message tells the user how to add coins.
  • Save watchlist_page.dart.
  • Hot restart your app (press Shift+R in the terminal running flutter run).
  • You should see the Crypto Tracker app bar with a list of cryptocurrencies showing names, symbols, current prices, and color-coded percentage changes.

Seeing a blank screen or error?

  • If you see 'Something went wrong' with an error message, check that you replaced your-coingecko-demo-api-key-here in coin_remote_data_source.dart with your actual CoinGecko Demo API key.
  • If the emulator has no internet, check your machine's network connection and restart the emulator.
  • If the app crashes on startup, run flutter run again and check the terminal for the error message.
  • help me debug my Crypto Tracker app not loading data

Try pulling down on the coin list to trigger a refresh. The list should briefly reload with updated prices.

Tap the star icon on any coin, then tap the star icon in the app bar to navigate to the Watchlist page. Your starred coin appears there.

✔️ Awesome, I've got everything!

Great. Make sure all three files are saved before moving on.

ⓧ I'd like to double check the full code

Here is the complete code for all three UI files.

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import '../../domain/entities/coin.dart';
import '../bloc/watchlist_cubit.dart';

class CoinTile extends StatelessWidget {
  final Coin coin;

  const CoinTile({super.key, required this.coin});

  @override
  Widget build(BuildContext context) {
    final isPositive = coin.priceChangePercentage24h >= 0;

    return ListTile(
      leading: CircleAvatar(
        backgroundImage: NetworkImage(coin.image),
        backgroundColor: Colors.grey[200],
      ),
      title: Text(
        coin.name,
        style: const TextStyle(fontWeight: FontWeight.bold),
      ),
      subtitle: Text(
        coin.symbol.toUpperCase(),
        style: TextStyle(color: Colors.grey[600]),
      ),
      trailing: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          Column(
            mainAxisAlignment: MainAxisAlignment.center,
            crossAxisAlignment: CrossAxisAlignment.end,
            children: [
              Text(
                '\$${coin.currentPrice.toStringAsFixed(2)}',
                style: const TextStyle(fontWeight: FontWeight.bold),
              ),
              Text(
                '${isPositive ? '+' : ''}${coin.priceChangePercentage24h.toStringAsFixed(2)}%',
                style: TextStyle(
                  color: isPositive ? Colors.green : Colors.red,
                  fontSize: 12,
                ),
              ),
            ],
          ),
          const SizedBox(width: 8),
          BlocBuilder<WatchlistCubit, List<String>>(
            builder: (context, watchlist) {
              final isWatched = watchlist.contains(coin.id);
              return IconButton(
                icon: Icon(
                  isWatched ? Icons.star : Icons.star_border,
                  color: isWatched ? Colors.amber : Colors.grey,
                ),
                onPressed: () {
                  context.read<WatchlistCubit>().toggleCoin(coin.id);
                },
              );
            },
          ),
        ],
      ),
    );
  }
}
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import '../../domain/entities/coin.dart';
import '../bloc/coin_list_bloc.dart';
import '../bloc/watchlist_cubit.dart';
import '../widgets/coin_tile.dart';
import 'watchlist_page.dart';

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Crypto Tracker'),
        actions: [
          IconButton(
            icon: const Icon(Icons.star),
            onPressed: () {
              Navigator.push(
                context,
                MaterialPageRoute(
                  builder: (_) => const WatchlistPage(),
                ),
              );
            },
          ),
        ],
      ),
      body: BlocBuilder<CoinListBloc, CoinListState>(
        builder: (context, state) {
          if (state is CoinListLoading) {
            return const Center(child: CircularProgressIndicator());
          } else if (state is CoinListLoaded) {
            return RefreshIndicator(
              onRefresh: () async {
                context.read<CoinListBloc>().add(RefreshCoins());
              },
              child: ListView.builder(
                itemCount: state.coins.length,
                itemBuilder: (context, index) {
                  final coin = state.coins[index];
                  return CoinTile(coin: coin);
                },
              ),
            );
          } else if (state is CoinListError) {
            return Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Text(
                    'Something went wrong',
                    style: Theme.of(context).textTheme.titleMedium,
                  ),
                  const SizedBox(height: 8),
                  Text(state.message),
                  const SizedBox(height: 16),
                  ElevatedButton(
                    onPressed: () {
                      context.read<CoinListBloc>().add(FetchCoins());
                    },
                    child: const Text('Retry'),
                  ),
                ],
              ),
            );
          }
          return const SizedBox.shrink();
        },
      ),
    );
  }
}
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import '../bloc/coin_list_bloc.dart';
import '../bloc/watchlist_cubit.dart';
import '../widgets/coin_tile.dart';

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Watchlist'),
      ),
      body: BlocBuilder<WatchlistCubit, List<String>>(
        builder: (context, watchlist) {
          final coinListState = context.watch<CoinListBloc>().state;

          if (coinListState is CoinListLoaded) {
            final watchedCoins = coinListState.coins
                .where((coin) => watchlist.contains(coin.id))
                .toList();

            if (watchedCoins.isEmpty) {
              return const Center(
                child: Text('No coins in your watchlist yet.\nTap the star icon to add coins.'),
              );
            }

            return ListView.builder(
              itemCount: watchedCoins.length,
              itemBuilder: (context, index) {
                return CoinTile(coin: watchedCoins[index]);
              },
            );
          }

          return const Center(child: CircularProgressIndicator());
        },
      ),
    );
  }
}

Your app now displays live cryptocurrency data and supports pull-to-refresh and a watchlist. Next up, you will add a dedicated watchlist feature with local state management using Cubit.

Secret mission

Add Real-Time Search with Debouncing

Your crypto tracker shows a list of coins, but scrolling through 20 items to find one is tedious. In this secret mission, you'll add a search bar that filters coins as the user types. You'll use BLoC event transformers to debounce the input, preventing unnecessary work on every keystroke.

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 free CoinGecko Demo API, so there are no ongoing costs.

Resources you used:

  • Flutter project folder on your Desktop.
  • CoinGecko Demo API account and API key.
  • Flutter dependencies installed via pub (stored locally in your project).
  • Android emulator or physical device connection.

Keep everything running

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

  • Your Flutter project folder stays on your Desktop.
  • Your CoinGecko Demo API key remains active.
  • Your emulator or device connection stays as-is.

Pause - I'll come back to this later

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

  • Stop the Flutter debug session by pressing Ctrl+C in the terminal where the app is running.
  • Close the Android emulator to free up RAM.
  • Keep the project folder on your Desktop for easy access later.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is not reversible.

  • Stop the Flutter debug session by pressing Ctrl+C in your terminal.
  • Close the Android emulator.
  • Delete the project folder from your Desktop.
  • If you want to revoke your CoinGecko Demo API key, log in to your CoinGecko account, go to Developer's Dashboard, and delete the key.

That's a Wrap!

That's a Wrap!

Nice work! You just built a production-grade Flutter app the way senior developers do it.

You now have a crypto portfolio tracker built with the exact architecture pattern that hiring managers test for in Flutter interviews.

Here's what you learned:

  • Built a crypto portfolio tracker that fetches live market data from the CoinGecko API and displays real-time prices with pull-to-refresh.
  • Implemented Clean Architecture with proper separation of concerns across data, domain, and presentation layers.
  • Used the BLoC pattern with get_it dependency injection for predictable, testable state management with proper loading, error, and success states.
  • Secret Mission: Added real-time search with debounced input using BLoC event transformers.

Ready to quiz yourself?