Race-Proof Product Search

Build a Flutter search app that prevents stale asynchronous results.

Introduction

30 Second Summary

A search box can look correct while quietly showing an answer for an older query. Fast typing makes that mismatch easy to miss until a customer sees the wrong products.

In this project, you will build a product-search app with Flutter for a physical Android device. The app demonstrates a predictable asynchronous race condition plus the latest-request-wins rule that prevents stale updates.

What You'll Build

On your phone, the same rapid m to mo search ends with stale m in unprotected mode versus authoritative mo in protected mode.

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

  • A race comparison you can demonstrate on your phone, where identical fast searches end with stale m in unprotected mode versus authoritative mo in protected mode.
  • A layered MVVM feature with immutable UI state whose deterministic Repository plus ViewModel tests run without widget rendering.
  • A Flutter DevTools trace that shows build events concentrated in the switch plus results regions while the TextField subtree stays outside those rebuilds.
  • Secret Mission: Add a 300 ms debounce to protected searches while keeping the unprotected race demonstration intact.

Are there any prerequisites?

This project assumes you are comfortable shipping Flutter apps. You also need Android Studio plus a physical Android device with USB debugging enabled through a cable or an existing wireless connection.

Before We Start

Before the hands-on work begins, commit to building a layered Flutter product-search app that exposes stale asynchronous responses. You will then prove why latest-request-wins protection matters without removing the broken comparison mode.

Set Up the Android Project

A concurrency exercise is meaningful when you can trust the toolchain that runs it. An unverified setup can make device problems look like app problems.

In this step, you will verify the Flutter SDK version 3.47.2 against a physical Android device. You will scaffold a minimal project that displays Hello World! on the phone.

In this step, get ready to:
  • Verify the Flutter 3.47.2 toolchain against your physical Android device.
  • Create the Android-only race_proof_search project on your Desktop.
  • Deploy the generated Hello World! starter to your phone.
Verify the pinned toolchain

The SDK check establishes the compiler behind every later result. The device check confirms Flutter can see your physical hardware target.

Watch for device authorization

Your phone may display a debugging authorization prompt when Flutter checks connected devices. Keep the phone unlocked so the prompt stays visible.

  • Connect your physical Android device through USB or your existing wireless debugging connection.
  • Keep the device unlocked.
  • Run the toolchain checks below:
flutter --version
flutter doctor
flutter devices

What do these checks prove?

  • The flutter --version command identifies the installed Flutter SDK version.
  • The flutter doctor command reports the status of your Android development tooling.
  • The flutter devices command lists the deployment targets Flutter can currently see.

✔️ The version and device are ready

  • Confirm the version output shows 3.47.2 or a newer compatible stable release.
  • Confirm the environment report recognizes your Android tooling.
  • Confirm the device list includes your physical phone with Android as its platform.

Good. Your Flutter toolchain can build for the same physical device you will use throughout the project.

ⓧ I see an older stable version

An older SDK can generate different project files. Updating now keeps the scaffold aligned with this project.

  • Update the SDK on its current stable channel by running:
flutter upgrade

What does this command do?

The flutter upgrade command retrieves the newest Flutter SDK available on your current channel.

Upgrade having trouble?

Check that your computer can reach the internet. Confirm that your Flutter SDK directory allows your account to update its files.

Help me diagnose the Flutter SDK upgrade failure.

  • Repeat the toolchain checks after the upgrade finishes:
flutter --version
flutter doctor
flutter devices

What should the repeated checks confirm?

The version should now meet the project requirement. Your physical Android device should still appear in the device list.

ⓧ My physical device is missing

A missing phone usually points to its debugging connection or authorization state. Flutter needs both before it can deploy the starter app.

  • Unlock your physical Android device.
  • Reconnect the USB cable or restore your wireless debugging connection.
  • Accept the debugging authorization prompt if it appears on the phone.
  • Check the device list again by running:
flutter devices

What should the device check show?

Your phone should appear as a connected device with Android listed as its platform. This confirms Flutter can target the physical hardware.

Device still missing?

Confirm USB debugging remains enabled on the phone. Try another data-capable USB cable if the computer only charges the device.

Help me diagnose why Flutter cannot see my physical Android device.

Scaffold the Android project

The empty scaffold gives you a small starting point for the search architecture. The Android platform option keeps unrelated desktop and web targets out of this project.

  • Move to your Desktop by running this command:
cd ~/Desktop

What does this command do?

The cd command changes the terminal location to ~/Desktop. The new project folder will be easy to find there.

  • Create the Android-only empty project by running:
flutter create --empty --platforms=android race_proof_search

What does this command create?

The --empty flag creates a minimal lib/main.dart starter. The --platforms=android flag limits platform generation to Android.

Flutter creates the race_proof_search folder with android/, lib/main.dart, pubspec.yaml, and pubspec.lock inside it.

Scaffold command stopped?

Check whether a race_proof_search folder already exists on your Desktop. An existing folder with project files can prevent a clean scaffold.

Help me diagnose why the Flutter project scaffold did not complete.

The scaffold is ready when Android Studio can load its project files. Opening the generated folder also gives you a project-scoped terminal for deployment.

  • Search for Android Studio from your Linux application launcher.
  • Select Android Studio from the search results.
  • Select Open on the welcome screen.
  • Choose the ~/Desktop/race_proof_search folder.
  • Confirm the Project pane lists android/, lib/main.dart, pubspec.yaml, and pubspec.lock.

✔️ Awesome, I've got everything!

Great. Your Android-only Flutter scaffold is open with the generated starter files in place.

ⓧ I'd like to double check the full code

The race_proof_search scaffold contains the Android platform folder plus the generated Flutter project files. Its empty starter uses this exact lib/main.dart file.

import 'package:flutter/material.dart';

void main() {
  runApp(const MainApp());
}

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(body: Center(child: Text('Hello World!'))),
    );
  }
}

What should this starter do?

The generated MainApp renders a MaterialApp with Hello World! centered on its only screen.

Prove the deployment path

A valid project structure still needs a working route to the physical phone. This deployment proves the SDK can build the scaffold for your connected Android device.

  • Select the Terminal tool window at the bottom of Android Studio.
  • Move into the generated project folder by running:
cd ~/Desktop/race_proof_search

What does this command do?

This changes the terminal location to race_proof_search. Flutter can now read the project configuration from that folder.

The first Android build can take a few minutes while Flutter prepares the app. A quiet terminal during this first build can still be normal.

Keep the phone ready

Your phone may request debugging authorization during deployment. Flutter waits for that approval before it installs the starter app.

  • Keep your physical Android device unlocked during the deployment.
  • Accept any debugging authorization prompt that appears after the command starts.

Before you run the app, which screen do you expect the untouched scaffold to show?

  • Deploy the generated starter to your physical device by running:
flutter run

What does this command do?

  • The flutter run command builds the current project for a connected device.
  • Flutter installs the resulting Android app on your phone.
  • Flutter starts the app after installation completes.

You should see Hello World! centered on your physical Android device. This confirms the project can build and deploy through the verified toolchain.

Starter app not appearing?

  • Reconnect the USB cable if the physical device disappears during the build.
  • Approve any pending debugging authorization request on the phone.
  • Return to the earlier device check if Flutter targets a different device.

Help me debug why the Flutter starter does not run on my physical Android device.

That is the setup risk cleared. Next up, you will turn this starter into a layered search flow that exposes a stale response.

Build the Naive Search Flow

Your Flutter deployment path already works on the physical Android device. That gives this concurrency exercise a trustworthy place to fail.

You need to experience the stale-response bug before adding protection. This step builds a deliberately naive search flow where every completed request may update the screen.

In this step, get ready to:
  • Create Service and Repository boundaries around a deterministic product search.
  • Model immutable UI state in a constructor-injected ViewModel.
  • Run the app and reproduce a stale asynchronous response on your device.
Create the data layer

The MVVM structure keeps data access outside the screen. A Service simulates the backend while a Repository becomes the ViewModel's only data dependency.

Why use a deterministic service?

A public API could change speed between searches. Fixed delays guarantee that the one-character request finishes after the longer query, so you can reproduce the same race every time.

This keeps the lesson focused on completion order instead of network conditions.

  • Create the data directory inside lib/ from the Android Studio Project panel.
  • Create search_data.dart inside lib/data/ with the product model by pasting this code:
class Product {
  const Product({
    required this.name,
    required this.category,
  });

  final String name;
  final String category;
}

What does the product model hold?

  • The immutable name field holds the product label shown in search results.
  • The immutable category field lets the service match searches such as audio or accessories.
  • Save lib/data/search_data.dart.
  • Confirm the Android Studio Project panel lists search_data.dart inside lib/data/.

File in the wrong directory?

Check that search_data.dart sits inside the new lib/data/ directory. A file beside lib/ will not match the imports used later.

Ask for help if the file tree differs from this layout: help me place search_data.dart inside lib/data in my Flutter project

  • Append the service-specific failure type to lib/data/search_data.dart by pasting this code below Product:
class SearchServiceException implements Exception {
  const SearchServiceException(this.message);

  final String message;

  @override
  String toString() => message;
}

What does the exception represent?

The data layer throws SearchServiceException when the simulated backend fails. Its stable message gives the ViewModel a predictable error value for the failure state.

  • Save lib/data/search_data.dart.
  • Check the two completed data types by running static analysis from the Android Studio terminal:
flutter analyze

What does this check prove?

The analyzer checks the project for syntax and type problems. A clean result confirms that the model and exception are valid Dart code before the asynchronous service is added.

You should see static analysis finish without reporting an issue in search_data.dart.

Seeing an analysis issue?

Compare the braces around Product and SearchServiceException. Each class needs its own closing brace.

Use this if the analyzer still points to the file: help me diagnose the Dart analysis issue in my model and exception classes

  • Append the service contract and deterministic implementation to lib/data/search_data.dart by pasting this code below SearchServiceException:
abstract class ProductService {
  Future<List<Product>> search(String query);
}

class SimulatedProductService implements ProductService {
  SimulatedProductService({
    this.slowDelay = const Duration(milliseconds: 1200),
    this.fastDelay = const Duration(milliseconds: 250),
  });

  final Duration slowDelay;
  final Duration fastDelay;

  static const List<Product> _products = <Product>[
    Product(name: 'Mechanical Keyboard', category: 'Accessories'),
    Product(name: 'Portable Monitor', category: 'Displays'),
    Product(name: 'Smart Lamp', category: 'Home'),
    Product(name: 'Noise-Cancelling Headphones', category: 'Audio'),
    Product(name: 'USB-C Hub', category: 'Accessories'),
    Product(name: 'Travel Mouse', category: 'Accessories'),
  ];

  @override
  Future<List<Product>> search(String query) async {
    final normalizedQuery = query.trim().toLowerCase();
    final delay = normalizedQuery.length == 1 ? slowDelay : fastDelay;
    await Future<void>.delayed(delay);
    if (normalizedQuery == 'error') {
      throw const SearchServiceException('The simulated service failed.');
    }
    return _products.where((product) => product.name.toLowerCase().contains(normalizedQuery) || product.category.toLowerCase().contains(normalizedQuery)).toList(growable: false);
  }
}

How does the simulated search work?

  • The ProductService contract exposes an asynchronous search without revealing how results are obtained.
  • A one-character query waits for 1200 milliseconds. A longer query waits for 250 milliseconds.
  • The query error throws SearchServiceException so the UI can demonstrate a stable failure state.
  • The filter matches the normalized query against either the product name or category.
  • Save lib/data/search_data.dart.
  • Confirm the service compiles by running static analysis again:
flutter analyze

What does this analysis cover?

This pass checks the asynchronous method, the injected delay values, and the product filtering expression. A clean result confirms that the deterministic service is ready for a repository.

You should see the analysis complete without reporting an issue in SimulatedProductService.

Service code not compiling?

Check that SimulatedProductService implements ProductService and keeps the search method inside the class braces.

Use this for targeted help: help me debug the simulated product service without changing its delays

  • Append the repository boundary to lib/data/search_data.dart by pasting this code below SimulatedProductService:
abstract class ProductRepository {
  Future<List<Product>> search(String query);
}

class DefaultProductRepository implements ProductRepository {
  const DefaultProductRepository({required ProductService service}) : _service = service;
  final ProductService _service;
  @override
  Future<List<Product>> search(String query) => _service.search(query);
}

What does the repository own?

The ViewModel depends on ProductRepository instead of the simulated backend. DefaultProductRepository receives its service through the constructor and forwards searches across that boundary.

This separation lets later tests control data behavior without rendering the screen.

  • Save lib/data/search_data.dart.
  • Verify the completed data layer by running static analysis:
flutter analyze

What has static analysis confirmed?

The completed file now has valid model, error, service, and repository boundaries. The next file can depend on ProductRepository without knowing about the simulated service.

You should see the analysis finish without reporting an issue in lib/data/search_data.dart.

Repository type not found?

Confirm that ProductRepository appears after the closing brace of SimulatedProductService. Nested repository declarations will produce type errors.

Ask for help with the boundary here: help me fix the repository declarations in search_data.dart

✔️ Awesome, I've got everything!

Your data layer is complete. Save lib/data/search_data.dart before continuing.

ⓧ I'd like to double check the full code

Compare lib/data/search_data.dart with this complete file.

class Product {
  const Product({
    required this.name,
    required this.category,
  });

  final String name;
  final String category;
}

class SearchServiceException implements Exception {
  const SearchServiceException(this.message);

  final String message;

  @override
  String toString() => message;
}

abstract class ProductService {
  Future<List<Product>> search(String query);
}

class SimulatedProductService implements ProductService {
  SimulatedProductService({
    this.slowDelay = const Duration(milliseconds: 1200),
    this.fastDelay = const Duration(milliseconds: 250),
  });

  final Duration slowDelay;
  final Duration fastDelay;

  static const List<Product> _products = <Product>[
    Product(name: 'Mechanical Keyboard', category: 'Accessories'),
    Product(name: 'Portable Monitor', category: 'Displays'),
    Product(name: 'Smart Lamp', category: 'Home'),
    Product(name: 'Noise-Cancelling Headphones', category: 'Audio'),
    Product(name: 'USB-C Hub', category: 'Accessories'),
    Product(name: 'Travel Mouse', category: 'Accessories'),
  ];

  @override
  Future<List<Product>> search(String query) async {
    final normalizedQuery = query.trim().toLowerCase();
    final delay = normalizedQuery.length == 1 ? slowDelay : fastDelay;
    await Future<void>.delayed(delay);
    if (normalizedQuery == 'error') {
      throw const SearchServiceException('The simulated service failed.');
    }
    return _products.where((product) => product.name.toLowerCase().contains(normalizedQuery) || product.category.toLowerCase().contains(normalizedQuery)).toList(growable: false);
  }
}

abstract class ProductRepository {
  Future<List<Product>> search(String query);
}

class DefaultProductRepository implements ProductRepository {
  const DefaultProductRepository({required ProductService service}) : _service = service;
  final ProductService _service;
  @override
  Future<List<Product>> search(String query) => _service.search(query);
}

What should match?

Check every class name, constructor parameter, delay, product value, and search expression. This reference is the complete data-layer state for this step.

Model immutable UI state

The screen needs one immutable UI state snapshot that describes loading, success, empty, and failure outcomes. A ChangeNotifier ViewModel owns that snapshot and tells listening widgets when it changes.

  • Create the presentation directory inside lib/ from the Android Studio Project panel.
  • Create search_view_model.dart inside lib/presentation/ with the status and state types by pasting this code:
import 'package:flutter/foundation.dart';

import '../data/search_data.dart';

enum SearchStatus { idle, loading, success, empty, failure }

class SearchUiState {
  const SearchUiState.idle() : status = SearchStatus.idle, query = '', completedQuery = '', results = const <Product>[], errorMessage = null;
  SearchUiState({required this.status, required this.query, required this.completedQuery, required List<Product> results, this.errorMessage}) : results = List<Product>.unmodifiable(results);
  final SearchStatus status;
  final String query;
  final String completedQuery;
  final List<Product> results;
  final String? errorMessage;
}

How does the UI state work?

  • SearchStatus gives the screen a finite set of states to render.
  • SearchUiState.idle creates the initial state with no active or completed query.
  • List<Product>.unmodifiable prevents widgets from mutating the result list after the state is created.
  • completedQuery records which request last committed a result, making the race visible.
  • Save lib/presentation/search_view_model.dart.
  • Confirm the Android Studio Project panel lists the file inside lib/presentation/.

State file showing import issues?

Check that search_view_model.dart is inside lib/presentation/. The relative import expects search_data.dart inside the sibling lib/data/ directory.

Use this for help with the file relationship: help me fix the relative import between the presentation and data layers

The naive ViewModel has no request identity or stale-response guard. Every repository completion can replace the current state, which creates the designed failure you will trigger shortly.

  • Begin SearchViewModel by appending this connected block below SearchUiState:
class SearchViewModel extends ChangeNotifier {
  SearchViewModel({required ProductRepository repository}) : _repository = repository;
  final ProductRepository _repository;
  SearchUiState _state = const SearchUiState.idle();
  bool _raceProtectionEnabled = false;
  int _startedSearchCount = 0;
  SearchUiState get state => _state;
  bool get raceProtectionEnabled => _raceProtectionEnabled;
  int get startedSearchCount => _startedSearchCount;
  void setRaceProtection(bool enabled) {
    if (_raceProtectionEnabled == enabled) return;
    _raceProtectionEnabled = enabled;
    notifyListeners();
  }

What does the ViewModel own?

  • _repository is supplied through the constructor, so the ViewModel never creates its own data dependency.
  • _state holds the latest immutable snapshot exposed to the screen.
  • _startedSearchCount makes every started request visible in the interface.
  • setRaceProtection updates the switch state, although this naive version has no protection logic yet.
  • Complete the same SearchViewModel class with its search method by pasting this code directly after the previous block:
  Future<void> search(String input) async {
    final query = input.trim();
    if (query.isEmpty) {
      _state = const SearchUiState.idle();
      notifyListeners();
      return;
    }
    _startedSearchCount++;
    _state = SearchUiState(status: SearchStatus.loading, query: query, completedQuery: _state.completedQuery, results: _state.results);
    notifyListeners();
    try {
      final results = await _repository.search(query);
      _state = SearchUiState(status: results.isEmpty ? SearchStatus.empty : SearchStatus.success, query: query, completedQuery: query, results: results);
    } on SearchServiceException catch (error) {
      _state = SearchUiState(status: SearchStatus.failure, query: query, completedQuery: query, results: const <Product>[], errorMessage: error.message);
    }
    notifyListeners();
  }
}

How does the naive search commit state?

  • An empty input restores the idle state immediately.
  • A non-empty input increments _startedSearchCount and publishes a loading state.
  • A successful completion publishes the returned products with its own query as completedQuery.
  • A service exception becomes a failure state with a stable error message.
  • Every completion calls notifyListeners regardless of when its request started.
  • Save lib/presentation/search_view_model.dart.
  • Verify the completed ViewModel by running static analysis:
flutter analyze

What does this check establish?

Static analysis now checks the imported data types, immutable state constructors, notifier methods, and asynchronous search flow together. A clean result confirms that the presentation logic is ready for the screen.

You should see the analysis finish without reporting an issue in search_view_model.dart.

ViewModel not compiling?

Check that the second block sits inside SearchViewModel and that the final brace closes the class. Also confirm that SearchUiState appears before the ViewModel.

Ask for targeted help here: help me diagnose the naive SearchViewModel structure

✔️ Awesome, I've got everything!

Your immutable state and naive ViewModel are complete. Save lib/presentation/search_view_model.dart before continuing.

ⓧ I'd like to double check the full code

Compare lib/presentation/search_view_model.dart with this complete file.

import 'package:flutter/foundation.dart';

import '../data/search_data.dart';

enum SearchStatus { idle, loading, success, empty, failure }

class SearchUiState {
  const SearchUiState.idle() : status = SearchStatus.idle, query = '', completedQuery = '', results = const <Product>[], errorMessage = null;
  SearchUiState({required this.status, required this.query, required this.completedQuery, required List<Product> results, this.errorMessage}) : results = List<Product>.unmodifiable(results);
  final SearchStatus status;
  final String query;
  final String completedQuery;
  final List<Product> results;
  final String? errorMessage;
}

class SearchViewModel extends ChangeNotifier {
  SearchViewModel({required ProductRepository repository}) : _repository = repository;
  final ProductRepository _repository;
  SearchUiState _state = const SearchUiState.idle();
  bool _raceProtectionEnabled = false;
  int _startedSearchCount = 0;
  SearchUiState get state => _state;
  bool get raceProtectionEnabled => _raceProtectionEnabled;
  int get startedSearchCount => _startedSearchCount;
  void setRaceProtection(bool enabled) {
    if (_raceProtectionEnabled == enabled) return;
    _raceProtectionEnabled = enabled;
    notifyListeners();
  }
  Future<void> search(String input) async {
    final query = input.trim();
    if (query.isEmpty) {
      _state = const SearchUiState.idle();
      notifyListeners();
      return;
    }
    _startedSearchCount++;
    _state = SearchUiState(status: SearchStatus.loading, query: query, completedQuery: _state.completedQuery, results: _state.results);
    notifyListeners();
    try {
      final results = await _repository.search(query);
      _state = SearchUiState(status: results.isEmpty ? SearchStatus.empty : SearchStatus.success, query: query, completedQuery: query, results: results);
    } on SearchServiceException catch (error) {
      _state = SearchUiState(status: SearchStatus.failure, query: query, completedQuery: query, results: const <Product>[], errorMessage: error.message);
    }
    notifyListeners();
  }
}

What should match?

Confirm that this version contains _raceProtectionEnabled and _startedSearchCount. It intentionally contains no request identifier or stale-response guard.

Build the screen and trigger the race

The UI keeps the text field outside its two ListenableBuilder regions. Notifier updates rebuild the mode tile and result panel while the input subtree stays outside those reactive boundaries.

  • Create search_screen.dart inside lib/presentation/ with the result panel by pasting this code:
import 'package:flutter/material.dart';

import 'search_view_model.dart';

class _SearchResults extends StatelessWidget {
  const _SearchResults({required this.state, required this.startedSearchCount});
  final SearchUiState state;
  final int startedSearchCount;
  @override
  Widget build(BuildContext context) => Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[Text('Typed query: ${state.query.isEmpty ? '(none)' : state.query}'), Text('Latest completed query: ${state.completedQuery.isEmpty ? '(none)' : state.completedQuery}'), Text('Requests started: $startedSearchCount'), Expanded(child: _buildBody())]);
  Widget _buildBody() {
    switch (state.status) {
      case SearchStatus.idle: return const Center(child: Text('Start typing to search.'));
      case SearchStatus.loading: return const Center(child: Text('Searching...'));
      case SearchStatus.empty: return const Center(child: Text('No products found.'));
      case SearchStatus.failure: return Center(child: Text(state.errorMessage ?? 'Search failed.'));
      case SearchStatus.success: return ListView.builder(itemCount: state.results.length, itemBuilder: (context, index) { final product = state.results[index]; return Padding(padding: const EdgeInsets.all(8), child: Text('${product.name}\n${product.category}')); });
    }
  }
}

What does the result panel reveal?

  • Typed query shows the query stored in the latest published state.
  • Latest completed query shows which asynchronous request most recently committed state.
  • Requests started exposes how many searches reached the repository.
  • _buildBody maps every status to a visible loading, empty, failure, idle, or success result.
  • Save lib/presentation/search_screen.dart.
  • Verify the result panel compiles by running static analysis:
flutter analyze

What has the analyzer checked?

This pass checks every rendered state and the product-list builder. A clean result confirms that the private result widget understands the ViewModel's state types.

You should see static analysis finish without reporting an issue in _SearchResults.

Result panel showing type errors?

Check that search_screen.dart imports search_view_model.dart and that every SearchStatus value has a switch case.

Use this if a state case still fails: help me debug the _SearchResults widget and its SearchStatus switch

  • Insert SearchScreen after the two import statements in lib/presentation/search_screen.dart by pasting this code:
class SearchScreen extends StatelessWidget {
  const SearchScreen({super.key, required this.viewModel});
  final SearchViewModel viewModel;
  @override
  Widget build(BuildContext context) => Scaffold(appBar: AppBar(title: const Text('Race-Proof Product Search')), body: Padding(padding: const EdgeInsets.all(16), child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[const Text('Try typing m and then mo quickly. Use error to test failure recovery.'), TextField(onChanged: viewModel.search, decoration: const InputDecoration(labelText: 'Search products', hintText: 'Try m, then mo')), ListenableBuilder(listenable: viewModel, builder: (context, child) => SwitchListTile(title: const Text('Race protection'), subtitle: Text(viewModel.raceProtectionEnabled ? 'Latest request wins' : 'Every response can overwrite the screen'), value: viewModel.raceProtectionEnabled, onChanged: viewModel.setRaceProtection)), Expanded(child: ListenableBuilder(listenable: viewModel, builder: (context, child) => _SearchResults(state: viewModel.state, startedSearchCount: viewModel.startedSearchCount)))])));
}

How is the screen divided?

  • The text field calls viewModel.search for every changed input value.
  • The first reactive region rebuilds the race-protection switch and its subtitle.
  • The second reactive region rebuilds _SearchResults when the ViewModel publishes state.
  • The input field remains outside both reactive regions, creating a rebuild boundary you will inspect later.
  • Save lib/presentation/search_screen.dart.
  • Confirm the completed screen compiles by running static analysis:
flutter analyze

What does this pass confirm?

Static analysis now checks the screen, both reactive regions, the search callback, and the status panel together. A clean result confirms that the screen is ready to receive an injected ViewModel.

You should see the analysis finish without reporting an issue in search_screen.dart.

Screen widget not compiling?

Confirm that SearchScreen appears after the imports and before _SearchResults. Also check that both builders receive the same viewModel instance.

Ask for focused help here: help me debug the SearchScreen builder structure

✔️ Awesome, I've got everything!

Your screen and its rebuild boundaries are complete. Save lib/presentation/search_screen.dart before wiring the app root.

ⓧ I'd like to double check the full code

Compare lib/presentation/search_screen.dart with this complete file.

import 'package:flutter/material.dart';

import 'search_view_model.dart';

class SearchScreen extends StatelessWidget {
  const SearchScreen({super.key, required this.viewModel});
  final SearchViewModel viewModel;
  @override
  Widget build(BuildContext context) => Scaffold(appBar: AppBar(title: const Text('Race-Proof Product Search')), body: Padding(padding: const EdgeInsets.all(16), child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[const Text('Try typing m and then mo quickly. Use error to test failure recovery.'), TextField(onChanged: viewModel.search, decoration: const InputDecoration(labelText: 'Search products', hintText: 'Try m, then mo')), ListenableBuilder(listenable: viewModel, builder: (context, child) => SwitchListTile(title: const Text('Race protection'), subtitle: Text(viewModel.raceProtectionEnabled ? 'Latest request wins' : 'Every response can overwrite the screen'), value: viewModel.raceProtectionEnabled, onChanged: viewModel.setRaceProtection)), Expanded(child: ListenableBuilder(listenable: viewModel, builder: (context, child) => _SearchResults(state: viewModel.state, startedSearchCount: viewModel.startedSearchCount)))])));
}

class _SearchResults extends StatelessWidget {
  const _SearchResults({required this.state, required this.startedSearchCount});
  final SearchUiState state;
  final int startedSearchCount;
  @override
  Widget build(BuildContext context) => Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[Text('Typed query: ${state.query.isEmpty ? '(none)' : state.query}'), Text('Latest completed query: ${state.completedQuery.isEmpty ? '(none)' : state.completedQuery}'), Text('Requests started: $startedSearchCount'), Expanded(child: _buildBody())]);
  Widget _buildBody() {
    switch (state.status) {
      case SearchStatus.idle: return const Center(child: Text('Start typing to search.'));
      case SearchStatus.loading: return const Center(child: Text('Searching...'));
      case SearchStatus.empty: return const Center(child: Text('No products found.'));
      case SearchStatus.failure: return Center(child: Text(state.errorMessage ?? 'Search failed.'));
      case SearchStatus.success: return ListView.builder(itemCount: state.results.length, itemBuilder: (context, index) { final product = state.results[index]; return Padding(padding: const EdgeInsets.all(8), child: Text('${product.name}\n${product.category}')); });
    }
  }
}

What should match?

Confirm that TextField sits outside both ListenableBuilder regions. The result panel must display the typed query, latest completed query, and request count.

  • Replace the generated contents of lib/main.dart with the app composition code below:
import 'package:flutter/material.dart';

import 'data/search_data.dart';
import 'presentation/search_screen.dart';
import 'presentation/search_view_model.dart';

void main() {
  final service = SimulatedProductService();
  final repository = DefaultProductRepository(service: service);
  final viewModel = SearchViewModel(repository: repository);
  runApp(SearchApp(viewModel: viewModel));
}

class SearchApp extends StatefulWidget {
  const SearchApp({super.key, required this.viewModel});
  final SearchViewModel viewModel;
  @override
  State<SearchApp> createState() => _SearchAppState();
}

class _SearchAppState extends State<SearchApp> {
  @override
  void dispose() { widget.viewModel.dispose(); super.dispose(); }
  @override
  Widget build(BuildContext context) => MaterialApp(title: 'Race-Proof Search', home: SearchScreen(viewModel: widget.viewModel));
}

How does dependency injection work here?

  • main creates one SimulatedProductService instance.
  • DefaultProductRepository receives that service through its constructor.
  • SearchViewModel receives the repository through its constructor.
  • SearchApp owns the ViewModel lifecycle and disposes it when the app widget is removed.
  • Save lib/main.dart.
  • Confirm the complete dependency chain compiles by running static analysis:
flutter analyze

What has the final analysis checked?

The analyzer now checks all four files as one application. A clean result confirms that the service, repository, ViewModel, and screen use compatible types.

You should see static analysis finish without reporting an issue in the project.

App root showing missing imports?

Check that the three relative imports in lib/main.dart match the files under lib/data/ and lib/presentation/.

Use this if the dependency chain still fails: help me trace the imports and constructor injection in main.dart

✔️ Awesome, I've got everything!

Your app root now injects the Service into the Repository and the Repository into the ViewModel. Save lib/main.dart before deploying.

ⓧ I'd like to double check the full code

Compare lib/main.dart with this complete file.

import 'package:flutter/material.dart';

import 'data/search_data.dart';
import 'presentation/search_screen.dart';
import 'presentation/search_view_model.dart';

void main() {
  final service = SimulatedProductService();
  final repository = DefaultProductRepository(service: service);
  final viewModel = SearchViewModel(repository: repository);
  runApp(SearchApp(viewModel: viewModel));
}

class SearchApp extends StatefulWidget {
  const SearchApp({super.key, required this.viewModel});
  final SearchViewModel viewModel;
  @override
  State<SearchApp> createState() => _SearchAppState();
}

class _SearchAppState extends State<SearchApp> {
  @override
  void dispose() { widget.viewModel.dispose(); super.dispose(); }
  @override
  Widget build(BuildContext context) => MaterialApp(title: 'Race-Proof Search', home: SearchScreen(viewModel: widget.viewModel));
}

What should match?

Confirm the construction order is service, repository, then ViewModel. The same ViewModel instance must reach SearchScreen and be disposed by _SearchAppState.

The app is assembled. The next run replaces the starter screen with the product-search interface on the connected device.

  • Return to the Android Studio terminal from earlier.
  • Deploy the new search app to the connected physical Android device by running:
flutter run

What does this run prove?

The command builds the current project and deploys it through the device connection verified earlier. It also keeps the terminal attached to the running app for development updates.

You should see a screen titled Race-Proof Product Search with a search field, a race-protection switch, and three diagnostic values.

Search screen not appearing?

Confirm the device remains unlocked and connected. If the starter screen remains visible, check that you saved all four files before running the app.

Ask for deployment help here: help me diagnose why my updated Flutter screen is not running on my Android device

Before you type, consider which query you expect to own the screen after both requests finish.

  • Leave the Race protection switch off.
  • Tap the Search products field.
  • Type m.
  • Immediately type o.
  • Wait for both searches to finish.

You will briefly see Latest completed query: mo. The slower first request then finishes and changes it to Latest completed query: m.

The text field still contains mo while Typed query and Latest completed query show m. The older completion has overwritten the newer result.

Why did the older query win?

The m request starts first with the longer delay. The mo request starts second and completes sooner.

The naive ViewModel commits every completion. Arrival order becomes screen order, so the last response to arrive owns the final state.

You found the bug on purpose. The app now makes a stale asynchronous write visible on real hardware.

Next, you will give each request a monotonic identity and prevent stale success or failure completions from changing the screen.

Reject Stale Responses

Your Flutter app now makes an asynchronous race condition visible. A slower search can finish last and replace the result for the latest typed query.

This step gives each search a request ID. The ViewModel uses that ID to keep older completions from changing the current UI state.

In this step, get ready to:
  • Assign a monotonic ID to every search request.
  • Guard successful results and service failures from stale requests.
  • Compare protected and unprotected behavior on the same device.
Give every request a monotonic identity

A monotonic request ID only moves upward. The most recently started search always owns the largest value.

  • Select lib/presentation/search_view_model.dart from the Project panel in Android Studio.
  • Find bool _raceProtectionEnabled = false; inside SearchViewModel.
  • Add this request counter directly below that line:
int _latestRequestId = 0;

What does the counter represent?

  • The _latestRequestId field records the newest valid request identity.
  • Each later request receives a larger integer.
  • Find _startedSearchCount++; inside search().
  • Add this request capture directly above that line:
final requestId = ++_latestRequestId;

Why capture a local request ID?

  • The prefix increment gives the new search the next integer.
  • The local requestId preserves that search's identity while the repository call is awaiting a result.
  • A newer search can update _latestRequestId without changing the older request's local value.
  • Save lib/presentation/search_view_model.dart.
  • Trigger a hot restart from the existing Android Studio run session.
  • Replace the search text with audio.

You'll see Noise-Cancelling Headphones in the results. The Requests started value increases when the new request begins.

App fails after the request ID edits?

  • Check that _latestRequestId sits inside SearchViewModel beside the other fields.
  • Check that requestId sits inside search() before _startedSearchCount++;.

Help me debug the request ID additions in my SearchViewModel.

Guard success and failure commits

A request ID becomes useful when every state commit checks it. Mode changes and cleared input must also invalidate work that is already running.

  • Find _raceProtectionEnabled = enabled; inside setRaceProtection().
  • Add this invalidation directly below that line:
_latestRequestId++;

Why invalidate on mode changes?

Changing modes creates a new request boundary. Any search started under the previous mode now holds an older ID.

  • Find the if (query.isEmpty) block inside search().
  • Add this invalidation before the idle state assignment:
_latestRequestId++;

Why invalidate cleared searches?

Clearing the field makes the idle state current. Incrementing the request ID prevents pending work from remaining authoritative.

  • Save lib/presentation/search_view_model.dart.
  • Trigger a hot restart from the existing Android Studio run session.
  • Toggle Race protection once.

You'll see the switch subtitle change to Latest request wins. This confirms the updated ViewModel still notifies the UI when its mode changes.

  • Find final results = await _repository.search(query); inside the try block.
  • Add the guard below that repository call by using this snippet:
  • Find on SearchServiceException catch (error) inside the same method.
  • Add the same guard as the first statement in that error path.
if (_raceProtectionEnabled && requestId != _latestRequestId) return;

How does the stale-response guard work?

  • The guard only rejects responses while race protection is enabled.
  • A matching ID proves that no newer search has started.
  • The success guard protects result state.
  • The failure guard protects the screen from an outdated service error.
  • Save lib/presentation/search_view_model.dart.

✔️ Awesome, I've got everything!

Great. Your ViewModel now gives each request an identity and guards both completion paths.

ⓧ I'd like to double check the full code

  • Compare lib/presentation/search_view_model.dart with this completed file:
import 'package:flutter/foundation.dart';

import '../data/search_data.dart';

enum SearchStatus { idle, loading, success, empty, failure }

class SearchUiState {
  const SearchUiState.idle() : status = SearchStatus.idle, query = '', completedQuery = '', results = const <Product>[], errorMessage = null;
  SearchUiState({required this.status, required this.query, required this.completedQuery, required List<Product> results, this.errorMessage}) : results = List<Product>.unmodifiable(results);
  final SearchStatus status;
  final String query;
  final String completedQuery;
  final List<Product> results;
  final String? errorMessage;
}

class SearchViewModel extends ChangeNotifier {
  SearchViewModel({required ProductRepository repository}) : _repository = repository;
  final ProductRepository _repository;
  SearchUiState _state = const SearchUiState.idle();
  bool _raceProtectionEnabled = false;
  int _latestRequestId = 0;
  int _startedSearchCount = 0;
  SearchUiState get state => _state;
  bool get raceProtectionEnabled => _raceProtectionEnabled;
  int get startedSearchCount => _startedSearchCount;
  void setRaceProtection(bool enabled) {
    if (_raceProtectionEnabled == enabled) return;
    _raceProtectionEnabled = enabled;
    _latestRequestId++;
    notifyListeners();
  }
  Future<void> search(String input) async {
    final query = input.trim();
    if (query.isEmpty) {
      _latestRequestId++;
      _state = const SearchUiState.idle();
      notifyListeners();
      return;
    }
    final requestId = ++_latestRequestId;
    _startedSearchCount++;
    _state = SearchUiState(status: SearchStatus.loading, query: query, completedQuery: _state.completedQuery, results: _state.results);
    notifyListeners();
    try {
      final results = await _repository.search(query);
      if (_raceProtectionEnabled && requestId != _latestRequestId) return;
      _state = SearchUiState(status: results.isEmpty ? SearchStatus.empty : SearchStatus.success, query: query, completedQuery: query, results: results);
    } on SearchServiceException catch (error) {
      if (_raceProtectionEnabled && requestId != _latestRequestId) return;
      _state = SearchUiState(status: SearchStatus.failure, query: query, completedQuery: query, results: const <Product>[], errorMessage: error.message);
    }
    notifyListeners();
  }
}
Compare both modes on your device

The same fast input now demonstrates both policies. Unprotected mode commits every response while protected mode commits only the latest request.

Before you test, consider whether m or mo will own the final state in each mode.

  • Trigger a hot restart from the existing Android Studio run session.
  • Turn Race protection off.
  • Clear the search field.
  • Type m.
  • Immediately type o.

After loading settles, you'll see Latest completed query: m. The text field still shows mo.

  • Clear the search field.
  • Turn Race protection on.
  • Type m.
  • Immediately type o.

After both requests finish, you'll see Latest completed query: mo. The older m response finishes without replacing the protected state.

  • Replace the search text with error.

You'll see The simulated service failed. in the results panel. The app remains responsive in its failure state.

  • Replace the search text with audio.

You'll see Noise-Cancelling Headphones again. This proves that a valid query recovers the UI without restarting the app.

Protected search still ends on m?

  • Confirm that Race protection is on before typing m.
  • Confirm that the request ID guard appears after the repository returns results.
  • Confirm that the same guard appears inside the service error path.
  • Confirm that requestId is captured before the repository call begins.

Help me diagnose why an older Flutter search response still replaces my protected state.

That is the concurrency bug contained. Next, you will lock this behavior down with automated unit tests.

Test the Race Behavior

Your app now exposes the stale write when protection is off. It blocks the same write when protection is on.

Timing bugs can return when small code changes alter completion order. In this step, deterministic Flutter tests turn that completion order into repeatable evidence.

In this step, get ready to:
  • Verify repository category filtering independently of widget rendering.
  • Prove the ViewModel's request-order behavior under both race modes.
  • Finish with clean static analysis plus a passing test suite.
Test the repository boundary

The repository is the ViewModel's only data dependency. Its test can prove category filtering without building the screen.

  • Use the Android Studio project sidebar to create test/search_data_test.dart inside race_proof_search.
  • Paste this code into test/search_data_test.dart:
import 'package:flutter_test/flutter_test.dart';
import 'package:race_proof_search/data/search_data.dart';

void main() {
  test('repository filters by product category', () async {
    final service = SimulatedProductService(slowDelay: Duration.zero, fastDelay: Duration.zero);
    final repository = DefaultProductRepository(service: service);
    final results = await repository.search('audio');
    expect(results.length, 1);
    expect(results[0].name, 'Noise-Cancelling Headphones');
  });
}

What Does This Test Prove?

  • The zero-duration service delays remove waiting from this filtering check.
  • The test searches through DefaultProductRepository without rendering a widget.
  • The assertions prove that the audio category returns one matching product.
  • Save test/search_data_test.dart.
  • Confirm the repository behavior by running:
flutter test

What Does This Test Run Check?

The Flutter test runner discovers the new file. It executes the repository assertion against the deterministic service.

You should see one test pass. The repository can now filter products by category independently of the UI.

Repository Test Not Passing?

  • Check that the file path is exactly test/search_data_test.dart.
  • Confirm the package import contains race_proof_search.
  • Compare the expected product name with Noise-Cancelling Headphones in lib/data/search_data.dart.

Help me diagnose why the repository category test does not pass.

✔️ Awesome, I've got everything!

Your repository boundary now has a passing category-filter test.

ⓧ I'd like to double check the full code

  • Compare your complete test/search_data_test.dart file with this reference:
import 'package:flutter_test/flutter_test.dart';
import 'package:race_proof_search/data/search_data.dart';

void main() {
  test('repository filters by product category', () async {
    final service = SimulatedProductService(slowDelay: Duration.zero, fastDelay: Duration.zero);
    final repository = DefaultProductRepository(service: service);
    final results = await repository.search('audio');
    expect(results.length, 1);
    expect(results[0].name, 'Noise-Cancelling Headphones');
  });
}
Prove both completion orders

Each ViewModel test starts the same two searches in a known order. Injected delays decide which result arrives first.

  • Use the Android Studio project sidebar to create test/search_view_model_test.dart inside race_proof_search.
  • Paste the shared test setup plus the unprotected scenario into test/search_view_model_test.dart:
import 'package:flutter_test/flutter_test.dart';
import 'package:race_proof_search/data/search_data.dart';
import 'package:race_proof_search/presentation/search_view_model.dart';

SearchViewModel buildViewModel() {
  final service = SimulatedProductService(slowDelay: const Duration(milliseconds: 40), fastDelay: const Duration(milliseconds: 5));
  return SearchViewModel(repository: DefaultProductRepository(service: service));
}

void main() {
  test('unprotected search allows an older response to overwrite state', () async {
    final viewModel = buildViewModel();
    final firstSearch = viewModel.search('m');
    await Future<void>.delayed(const Duration(milliseconds: 1));
    final secondSearch = viewModel.search('mo');
    await secondSearch;
    await firstSearch;
    expect(viewModel.state.completedQuery, 'm');
    expect(viewModel.startedSearchCount, 2);
    viewModel.dispose();
  });
}

How Does This Recreate the Race?

  • The buildViewModel() helper gives one-character searches the longer delay.
  • The test starts m before it starts mo.
  • Awaiting secondSearch first confirms that the newer request completes before the older request.
  • The final assertions preserve the intentional stale-overwrite behavior when protection is off.
  • Save test/search_view_model_test.dart.
  • Before you run the suite, predict which query will own the final state when protection remains off.
  • Run the tests by running:
flutter test

What Does This Test Run Check?

The suite now runs the repository test plus the unprotected ViewModel test. The second test recreates the stale overwrite without tapping the device.

You should see two tests pass. The unprotected test confirms that the older m request owns the final state after finishing last.

Unprotected Race Test Failing?

  • Confirm that firstSearch starts with m.
  • Confirm that secondSearch starts with mo.
  • Check that the test awaits secondSearch before firstSearch.

Help me debug the deterministic unprotected race test.

  • In test/search_view_model_test.dart, find the final closing brace for main().
  • Paste this protected scenario immediately above that brace:
  test('protected search ignores an older response', () async {
    final viewModel = buildViewModel();
    viewModel.setRaceProtection(true);
    final firstSearch = viewModel.search('m');
    await Future<void>.delayed(const Duration(milliseconds: 1));
    final secondSearch = viewModel.search('mo');
    await secondSearch;
    await firstSearch;
    expect(viewModel.state.completedQuery, 'mo');
    expect(viewModel.state.results.length, 1);
    expect(viewModel.state.results[0].name, 'Portable Monitor');
    viewModel.dispose();
  });

How Does Protection Change the Result?

  • The test enables protection before either request starts.
  • The same delays still let mo finish before m.
  • The assertions prove that mo remains authoritative after both requests finish.
  • The product assertion proves that the committed results belong to the latest query.
  • Save test/search_view_model_test.dart.
  • Before you run the suite, predict whether the slower m response can still replace mo.
  • Run the tests by running:
flutter test

What Does This Test Run Check?

The new test repeats the same completion order with race protection enabled. Its assertions check the committed query plus the matching product result.

You should see three tests pass. The protected test confirms that mo remains the final completed query.

Protected Test Not Keeping the Latest Query?

  • Confirm that setRaceProtection(true) runs before firstSearch starts.
  • Check that the expected completed query is mo.
  • Compare both stale-response guards in lib/presentation/search_view_model.dart with the protected implementation from the previous step.

Help me find why the protected ViewModel test allows an older response to commit.

  • Return to the final closing brace for main() in test/search_view_model_test.dart.
  • Paste this failure-state scenario immediately above that brace:
  test('service errors become failure state', () async {
    final viewModel = buildViewModel();
    await viewModel.search('error');
    expect(viewModel.state.status, SearchStatus.failure);
    expect(viewModel.state.errorMessage, 'The simulated service failed.');
    viewModel.dispose();
  });

What Does the Failure Test Prove?

  • The error query triggers the service's controlled exception.
  • The status assertion proves that the ViewModel converts the exception into SearchStatus.failure.
  • The message assertion proves that the failure state preserves useful error details.
  • Save test/search_view_model_test.dart.
  • Run the expanded test suite by running:
flutter test

What Does This Test Run Check?

The suite now covers repository filtering plus three ViewModel outcomes. The latest test verifies controlled failure-state conversion.

You should see four tests pass. The failure test confirms that the service exception becomes stable UI state.

Failure-State Test Not Passing?

  • Check that the test searches for error.
  • Confirm that the expected status is SearchStatus.failure.
  • Compare the expected message with SearchServiceException in lib/data/search_data.dart.

Help me debug why the service exception test does not produce failure state.

✔️ Awesome, I've got everything!

Your ViewModel suite now proves the unprotected race, the protected result, plus failure-state conversion.

ⓧ I'd like to double check the full code

  • Compare your complete test/search_view_model_test.dart file with this reference:
import 'package:flutter_test/flutter_test.dart';
import 'package:race_proof_search/data/search_data.dart';
import 'package:race_proof_search/presentation/search_view_model.dart';

SearchViewModel buildViewModel() {
  final service = SimulatedProductService(slowDelay: const Duration(milliseconds: 40), fastDelay: const Duration(milliseconds: 5));
  return SearchViewModel(repository: DefaultProductRepository(service: service));
}

void main() {
  test('unprotected search allows an older response to overwrite state', () async {
    final viewModel = buildViewModel();
    final firstSearch = viewModel.search('m');
    await Future<void>.delayed(const Duration(milliseconds: 1));
    final secondSearch = viewModel.search('mo');
    await secondSearch;
    await firstSearch;
    expect(viewModel.state.completedQuery, 'm');
    expect(viewModel.startedSearchCount, 2);
    viewModel.dispose();
  });
  test('protected search ignores an older response', () async {
    final viewModel = buildViewModel();
    viewModel.setRaceProtection(true);
    final firstSearch = viewModel.search('m');
    await Future<void>.delayed(const Duration(milliseconds: 1));
    final secondSearch = viewModel.search('mo');
    await secondSearch;
    await firstSearch;
    expect(viewModel.state.completedQuery, 'mo');
    expect(viewModel.state.results.length, 1);
    expect(viewModel.state.results[0].name, 'Portable Monitor');
    viewModel.dispose();
  });
  test('service errors become failure state', () async {
    final viewModel = buildViewModel();
    await viewModel.search('error');
    expect(viewModel.state.status, SearchStatus.failure);
    expect(viewModel.state.errorMessage, 'The simulated service failed.');
    viewModel.dispose();
  });
}
Run static analysis and all tests

Runtime assertions protect behavior. Static analysis checks the source for problems before the final test run.

  • Before you run the checks, predict whether any analyzer issue or failed assertion remains.
  • Run static analysis plus the complete test suite by running:
flutter analyze
flutter test

What Do These Checks Prove?

  • The first command checks the Flutter project for static analysis issues.
  • The second command runs every repository plus ViewModel test.
  • Together, the checks verify source quality plus deterministic race behavior.

You should see static analysis complete without issues. The test runner should report all four tests passing.

Final Checks Not Clean?

  • Read the first reported file path to identify which test or production file needs attention.
  • Compare both test files with the full-code references above.
  • Rerun the checks after saving every changed file.

Help me diagnose a Flutter analysis issue or failing race-behavior test.

That locks the race contract in place. Your app's asynchronous behavior now has repeatable evidence behind it.

Next, you will run the tested app in profile mode. Flutter DevTools will show whether notifier updates stay inside the intended widget regions.

Profile Rebuild Boundaries

Your automated tests now prove that the Flutter search app behaves correctly under controlled timing.

Automated correctness still leaves rebuild cost unmeasured. Frequent input should leave unrelated widgets outside the reactive rebuild path.

You will use Flutter DevTools on your physical Android device to capture that runtime evidence. No source or test files change in this step.

In this step, get ready to:
  • Launch the tested app on the physical device in profile mode.
  • Record widget build events while searching and changing race protection.
  • Confirm notifier-driven builds stay within the switch and results regions.
Prepare the physical-device profile session

Flutter performance guidance uses profile mode on physical devices because debug builds and emulators do not represent release performance. The profile session stays attached to its terminal until you stop it.

  • Return to the terminal in Android Studio from earlier inside the race_proof_search folder.
  • Stop any earlier debug run by pressing Ctrl+C.
  • Keep the connected physical Android device unlocked.
  • Launch the app in profile mode by running this command:
flutter run --profile

What does this command do?

The profile run launches the app on the connected device with performance tracing support. This gives DevTools a representative session to inspect.

  • Check the connected physical Android device for the Race-Proof Product Search screen.

You'll see the tested search app running on your phone. That is the first checkpoint complete: your app is now traceable on real hardware.

App not launching on the device?

  • Confirm the physical Android device is still unlocked.
  • Reconnect the USB cable if the device has disconnected.
  • Accept the debugging authorization prompt if the phone displays one.

Help me diagnose why my Flutter profile build is not launching on the connected Android device.

Record widget builds in DevTools

Widget build tracing adds build() events and widget names to the performance timeline. The tracing control sits inside the Performance view's enhance tracing options, so it is easy to miss.

  • Return to Android Studio.
  • Open Flutter DevTools for the running profile app from Android Studio.
  • Select the Performance view.
  • Expand the enhance tracing options.
  • Enable Track Widget Builds.

What does widget build tracing add?

The tracing option records which widgets run their build methods during each captured frame. That evidence reveals whether a state notification reaches only the intended reactive regions.

  • Start a recording in the Performance view.
  • Return to the physical Android device.
  • Tap the Search products field.
  • Type m.
  • Immediately type o in the same field.
  • Turn on Race protection.
  • Return to Flutter DevTools.
  • Stop the Performance recording.

Your recording now contains the m to mo interaction plus the race-protection change. You have captured the exact activity needed to inspect the app's rebuild boundaries.

Recording missing build events?

  • Confirm that Track Widget Builds was enabled before the recording started.
  • Repeat the search interaction while the Performance recording is active.
  • Confirm that DevTools remains connected to the profile-mode app.

Help me find why my Flutter DevTools recording does not show widget build events.

Read the rebuild evidence

A performance trace becomes useful when you connect each event to a reactive boundary in the widget tree. This screen places the TextField outside the two ListenableBuilder regions.

Before you inspect the final trace, where do you expect notifier-driven build events to cluster?

  • Select the interaction frames in the Flutter frames chart.
  • Open the Timeline events tab for the selected frames.
  • Locate the build events associated with the SwitchListTile mode region.
  • Locate the build events associated with the _SearchResults region.
  • Compare those notifier-driven events with the TextField subtree.

You'll see notifier-driven build events concentrated around the SwitchListTile mode region and the _SearchResults region. These are the two areas that read changing ViewModel values.

The TextField remains outside the independent ListenableBuilder subtrees. That boundary prevents ViewModel notifications from rebuilding the input region.

You now have runtime evidence to match your passing tests. The race fix updates the intended regions without pulling unrelated input UI into notifier-driven rebuilds.

  • Keep the profile session and DevTools trace open for the checkpoint.

Secret mission

Debounce Protected Searches

Race protection keeps stale responses off the screen. Add a lifecycle-safe 300 ms debounce so rapid protected input starts one settled request while unprotected input preserves the race.

Clean Up Your Resources

Clean Up Your Resources

Everything runs locally through Flutter, so there are no ongoing charges. Cleanup affects only the work created for this app.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Your race_proof_search project directory containing the production files plus tests.
  • The active profile-mode process connected to your physical Android device.
  • The Flutter DevTools session containing your physical-device trace.
  • The development build installed on your phone.

Keep everything running

No action needed. Choose this if you are still comparing protected input with the unprotected race.

  • Keep the race_proof_search directory so the complete app remains available.
  • Keep the profile session active while you inspect widget build events.
  • Keep the development build on your phone for another demonstration.
  • Keep the current DevTools session available for trace comparisons.

Pause - I'll come back to this later

Stop the live profiling tools to free up memory. Your project files remain available for another session.

  • Return to the terminal panel that ran the profile build.
  • Press Ctrl+C to stop the active process.
  • Close Flutter DevTools.
  • Close Android Studio to release its memory.
  • Disconnect the physical device from your computer.
  • Leave the race_proof_search directory in its current location.

Your app source remains ready for the next profiling session.

Delete - I don't want to use this again

Deleting the local project is permanent. Your development tools remain installed.

  • Return to the terminal panel that ran the profile build.
  • Press Ctrl+C to stop the active process.
  • Close Flutter DevTools.
  • Close Android Studio.
  • Remove the development build using your physical Android device's app-uninstall controls.
  • Confirm the development build no longer appears in your phone's app list.
  • Delete the race_proof_search project directory by running these commands from the terminal panel:
cd ..
rm -rf race_proof_search

What Do These Commands Remove?

  • The first line moves the terminal one level above the project directory.
  • The second line permanently deletes the named directory with everything inside it.
  • Confirm the project directory is gone by running:
ls

What Does This Check?

The command lists the contents of the directory that previously contained your project. The missing project name confirms the local files were deleted.

You should no longer see race_proof_search in the output.

Still See the Project Directory?

Check that the terminal moved one level above race_proof_search before deletion.

Close any remaining process that is using the project before retrying.

Help me check why the project directory still appears.

Nice Work!

Nice Work!

You did it! You built a Flutter product-search app that makes an asynchronous race visible on a physical Android device. Its protected mode keeps the latest query authoritative under uneven completion times.

You've learned how to:

  • Structure a product-search app around MVVM boundaries with separate View, ViewModel, Repository, and Service responsibilities. Model loading, success, empty, and failure as immutable UI state. Supply dependencies through constructor injection.
  • Reproduce a deterministic stale-response bug on a physical device. Enforce latest-request-wins sequencing for successful responses. Apply the same protection to failed requests.
  • Prove repository filtering, failure handling, and race behavior with automated tests. Use Flutter DevTools in profile mode to confirm that rebuilds stay focused on the switch and results regions.
  • Complete the Secret Mission by adding a lifecycle-safe 300 ms debounce to protected searches. Preserve immediate unprotected searches so the stale-response demonstration still works.

Ready to quiz yourself?