Skip to content
Blog

Building a Bottom Navigation App with package:listen

A step-by-step tutorial for creating a Flutter app with four-tab bottom navigation and per-screen state management using the listen package from the Flutter team.

Published on September 5, 2026

AI Assistant

Building a Bottom Navigation App with package:listen

A step-by-step tutorial for creating a Flutter app with four-tab bottom navigation and per-screen state management using the listen package from the Flutter team.

What You Will Build

A Flutter app with a NavigationBar containing four tabs — Feed, Product, Chat, and Settings — each with its own independent state that persists across tab switches. The Product tab shows a live cart badge, the Chat tab tracks unread messages, and every screen reacts instantly when its underlying state changes.

Prerequisites

  • Flutter 3.47.2+ / Dart 3.13.2+
  • A working Flutter project (flutter create listen_app)

1. Add the listen Package

flutter pub add listen

package:listen is a pure-Dart state management package by the Flutter team. It provides ChangeNotifier, ValueNotifier, and Listenable.merge — the same API surface as Flutter’s built-in classes, but without any Flutter dependency. This means you can model your state in a standalone Dart library if you want to.

2. Create the Listen Widget Bridge

Because package:listen types are not the same as Flutter’s built-in Listenable/ValueListenable, Flutter’s ValueListenableBuilder will not accept them. We write a small generic widget that subscribes to any listen.Listenable and triggers a rebuild.

// lib/widgets/listen.dart

import 'package:flutter/widgets.dart';
import 'package:listen/listen.dart' as listen;

class Listen<T extends listen.Listenable> extends StatefulWidget {
  const Listen({
    super.key,
    required this.listenable,
    required this.builder,
  });

  final T listenable;
  final Widget Function(BuildContext context, T listenable) builder;

  @override
  State<Listen<T>> createState() => _ListenState<T>();
}

class _ListenState<T extends listen.Listenable> extends State<Listen<T>> {
  @override
  void initState() {
    super.initState();
    widget.listenable.addListener(_handleChanged);
  }

  @override
  void didUpdateWidget(Listen<T> oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.listenable != widget.listenable) {
      oldWidget.listenable.removeListener(_handleChanged);
      widget.listenable.addListener(_handleChanged);
    }
  }

  @override
  void dispose() {
    widget.listenable.removeListener(_handleChanged);
    super.dispose();
  }

  void _handleChanged() {
    if (mounted) setState(() {});
  }

  @override
  Widget build(BuildContext context) => widget.builder(context, widget.listenable);
}

Key detail: The import ... as listen alias avoids name collisions with Flutter’s own Listenable and ChangeNotifier exports.

3. Build the State Layer

Every screen gets one listen.ChangeNotifier subclass. Notifiers live in a dedicated states/ directory.

3a. Feed State

// lib/states/feed_state.dart

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

class FeedPost {
  const FeedPost({
    required this.id,
    required this.author,
    required this.content,
    required this.likes,
  });

  final int id;
  final String author;
  final String content;
  final int likes;
}

class FeedState extends listen.ChangeNotifier {
  FeedState() {
    _posts.addAll(_initialPosts);
  }

  static const List<FeedPost> _initialPosts = <FeedPost>[
    FeedPost(id: 0, author: 'Alice', content: 'Just shipped the new dashboard to production!', likes: 42),
    FeedPost(id: 1, author: 'Bob', content: 'package:listen makes state management so much simpler.', likes: 128),
    FeedPost(id: 2, author: 'Carol', content: 'Anyone else trying Flutter 3.47? Loving the new features.', likes: 67),
    FeedPost(id: 3, author: 'Dan', content: 'Who is going to FlutterCon this year?', likes: 19),
  ];

  final List<FeedPost> _posts = <FeedPost>[];
  final Set<int> _likedIds = <int>{};
  int _nextId = _initialPosts.length;

  List<FeedPost> get posts => List<FeedPost>.unmodifiable(_posts);

  bool isLiked(int id) => _likedIds.contains(id);

  int likeCount(int id) {
    final int base = _posts.firstWhere((FeedPost post) => post.id == id).likes;
    return base + (isLiked(id) ? 1 : 0);
  }

  void publish(String author, String content) {
    _posts.insert(
      0,
      FeedPost(id: _nextId++, author: author, content: content.trim(), likes: 0),
    );
    notifyListeners();
  }

  void toggleLike(int id) {
    if (!_likedIds.remove(id)) {
      _likedIds.add(id);
    }
    notifyListeners();
  }
}

The pattern is the same for every state: hold private mutable data, expose read-only getters, mutate through methods that call notifyListeners().

3b. Product State

// lib/states/product_state.dart

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

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

  final String name;
  final String tagline;
  final double price;
}

class ProductState extends listen.ChangeNotifier {
  static const List<Product> _allProducts = <Product>[
    Product(name: 'Wireless Earbuds', tagline: 'Crystal clear sound', price: 79.99),
    Product(name: 'Smart Watch', tagline: 'Track every step', price: 199.99),
    Product(name: 'Mechanical Keyboard', tagline: 'Tactile perfection', price: 139.00),
    Product(name: 'USB-C Hub', tagline: 'Expand your ports', price: 49.50),
    Product(name: 'Desk Lamp', tagline: 'Warm light for late nights', price: 29.99),
  ];

  final Set<Product> _cart = <Product>{};

  List<Product> get products => _allProducts;

  int get cartCount => _cart.length;

  double get cartTotal =>
      _cart.fold(0.0, (double sum, Product product) => sum + product.price);

  bool inCart(Product product) => _cart.contains(product);

  void toggleCart(Product product) {
    if (!_cart.remove(product)) {
      _cart.add(product);
    }
    notifyListeners();
  }
}

3c. Chat State

// lib/states/chat_state.dart

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

class ChatThread {
  ChatThread({required this.name, required List<String> messages})
      : messages = List<String>.of(messages);

  final String name;
  final List<String> messages;

  String get lastMessage => messages.isEmpty ? 'No messages yet' : messages.last;
}

class ChatState extends listen.ChangeNotifier {
  ChatState() {
    _threads.addAll(_initialThreads);
    _unread.addAll(_initialUnread);
  }

  static final List<ChatThread> _initialThreads = <ChatThread>[
    ChatThread(name: 'Design Team', messages: <String>['Landed the new mockups', 'Could you review the feed screen?', 'On it!']),
    ChatThread(name: 'Flutter Group', messages: <String>['Anyone using package:listen?', 'Yes, loving it so far']),
    ChatThread(name: 'Emma', messages: <String>['See you at lunch tomorrow?', 'Definitely!']),
    ChatThread(name: 'Linus', messages: <String>['Did you see the Flutter release notes?']),
  ];

  static const Map<String, int> _initialUnread = <String, int>{
    'Design Team': 2,
    'Flutter Group': 0,
    'Emma': 1,
    'Linus': 0,
  };

  final List<ChatThread> _threads = <ChatThread>[];
  final Map<String, int> _unread = <String, int>{};
  int _selectedIndex = 0;

  List<ChatThread> get threads => List<ChatThread>.unmodifiable(_threads);

  int get selectedIndex => _selectedIndex;

  ChatThread get selected => _threads[_selectedIndex];

  int get totalUnread =>
      _unread.values.fold(0, (int sum, int count) => sum + count);

  int unreadFor(ChatThread thread) => _unread[thread.name] ?? 0;

  void select(int index) {
    _selectedIndex = index;
    _unread[_threads[index].name] = 0;
    notifyListeners();
  }

  void sendMessage(String text) {
    final String trimmed = text.trim();
    if (trimmed.isEmpty) return;
    _threads[_selectedIndex].messages.add(trimmed);
    notifyListeners();
  }
}

3d. Settings State

// lib/states/settings_state.dart

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

class SettingsState extends listen.ChangeNotifier {
  bool _notifications = true;
  bool _autoPlay = true;
  bool _reduceMotion = false;
  bool _stayOnline = true;

  bool get notifications => _notifications;
  bool get autoPlay => _autoPlay;
  bool get reduceMotion => _reduceMotion;
  bool get stayOnline => _stayOnline;

  void setNotifications(bool value) {
    _notifications = value;
    notifyListeners();
  }

  void setAutoPlay(bool value) {
    _autoPlay = value;
    notifyListeners();
  }

  void setReduceMotion(bool value) {
    _reduceMotion = value;
    notifyListeners();
  }

  void setStayOnline(bool value) {
    _stayOnline = value;
    notifyListeners();
  }
}

4. Build the Screens

Each screen is a StatelessWidget that receives its state via constructor injection and rebuilds via the Listen<T> widget.

4a. Feed Screen

// lib/screens/feed_screen.dart

import 'package:flutter/material.dart';
import '../states/feed_state.dart';
import '../widgets/listen.dart';

class FeedScreen extends StatelessWidget {
  const FeedScreen({super.key, required this.state});

  final FeedState state;

  @override
  Widget build(BuildContext context) {
    return Listen<FeedState>(
      listenable: state,
      builder: (BuildContext context, FeedState state) {
        return Scaffold(
          appBar: AppBar(title: const Text('Feed')),
          body: Column(
            children: <Widget>[
              _ComposeCard(state: state),
              Expanded(
                child: ListView.builder(
                  padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
                  itemCount: state.posts.length,
                  itemBuilder: (BuildContext context, int index) {
                    return _FeedCard(post: state.posts[index], state: state);
                  },
                ),
              ),
            ],
          ),
        );
      },
    );
  }
}

The _ComposeCard is a StatefulWidget so its TextEditingController survives across rebuilds triggered by Listen. When the user presses Send, it calls state.publish() which inserts a new post and fires notifyListeners().

The _FeedCard shows each post with a like button. Tapping the button calls state.toggleLike(id) — the like icon and count update immediately.

4b. Product Screen

// lib/screens/product_screen.dart

import 'package:flutter/material.dart';
import '../states/product_state.dart';
import '../widgets/listen.dart';

class ProductScreen extends StatelessWidget {
  const ProductScreen({super.key, required this.state});

  final ProductState state;

  @override
  Widget build(BuildContext context) {
    return Listen<ProductState>(
      listenable: state,
      builder: (BuildContext context, ProductState state) {
        return Scaffold(
          appBar: AppBar(
            title: const Text('Products'),
            actions: <Widget>[
              _CartSummary(state: state),
            ],
          ),
          body: ListView.builder(
            padding: const EdgeInsets.all(12),
            itemCount: state.products.length,
            itemBuilder: (BuildContext context, int index) {
              return _ProductTile(product: state.products[index], state: state);
            },
          ),
        );
      },
    );
  }
}

Each _ProductTile has an Add/Remove button. Toggling the cart updates the _CartSummary in the AppBar and the badge on the NavigationBar.

4c. Chat Screen

// lib/screens/chat_screen.dart

import 'package:flutter/material.dart';
import '../states/chat_state.dart';
import '../widgets/listen.dart';

class ChatScreen extends StatelessWidget {
  const ChatScreen({super.key, required this.state});

  final ChatState state;

  @override
  Widget build(BuildContext context) {
    return Listen<ChatState>(
      listenable: state,
      builder: (BuildContext context, ChatState state) {
        return Scaffold(
          appBar: AppBar(title: Text(state.selected.name)),
          body: Column(
            children: <Widget>[
              _ThreadSelector(state: state),
              Expanded(child: _MessageList(state: state)),
              _Composer(state: state),
            ],
          ),
        );
      },
    );
  }
}

The _ThreadSelector is a horizontal row of ChoiceChip widgets. Tapping one calls state.select(index) which switches the active thread and marks its unread count to zero. The _MessageList uses reverse: true so the latest message appears at the bottom. The _Composer (another StatefulWidget with its own TextEditingController) appends messages to the selected thread.

4d. Settings Screen

// lib/screens/settings_screen.dart

import 'package:flutter/material.dart';
import '../states/settings_state.dart';
import '../widgets/listen.dart';

class SettingsScreen extends StatelessWidget {
  const SettingsScreen({super.key, required this.state});

  final SettingsState state;

  @override
  Widget build(BuildContext context) {
    return Listen<SettingsState>(
      listenable: state,
      builder: (BuildContext context, SettingsState state) {
        return Scaffold(
          appBar: AppBar(title: const Text('Settings')),
          body: ListView(
            children: <Widget>[
              SwitchListTile(
                secondary: const Icon(Icons.notifications_outlined),
                title: const Text('Notifications'),
                subtitle: const Text('Receive push notifications'),
                value: state.notifications,
                onChanged: state.setNotifications,
              ),
              SwitchListTile(
                secondary: const Icon(Icons.play_circle_outline),
                title: const Text('Autoplay videos'),
                subtitle: const Text('Play previews automatically'),
                value: state.autoPlay,
                onChanged: state.setAutoPlay,
              ),
              SwitchListTile(
                secondary: const Icon(Icons.animation_outlined),
                title: const Text('Reduce motion'),
                subtitle: const Text('Minimize animations across the app'),
                value: state.reduceMotion,
                onChanged: state.setReduceMotion,
              ),
              SwitchListTile(
                secondary: const Icon(Icons.wifi_tethering_outlined),
                title: const Text('Stay online'),
                subtitle: const Text('Show your presence in chat'),
                value: state.stayOnline,
                onChanged: state.setStayOnline,
              ),
              const Divider(),
              const ListTile(
                leading: Icon(Icons.info_outline),
                title: Text('Version'),
                subtitle: Text('1.0.0'),
              ),
            ],
          ),
        );
      },
    );
  }
}

5. Wire Up the Home Page

The HomePage is the only StatefulWidget in the app. It owns all four state objects, creates the screens, and manages the selected tab index.

// lib/home_page.dart

import 'package:flutter/material.dart';
import 'package:listen/listen.dart' as listen;

import 'screens/chat_screen.dart';
import 'screens/feed_screen.dart';
import 'screens/product_screen.dart';
import 'screens/settings_screen.dart';
import 'states/chat_state.dart';
import 'states/feed_state.dart';
import 'states/product_state.dart';
import 'states/settings_state.dart';
import 'widgets/listen.dart';

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

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  final FeedState _feedState = FeedState();
  final ProductState _productState = ProductState();
  final ChatState _chatState = ChatState();
  final SettingsState _settingsState = SettingsState();

  late final List<Widget> _screens = <Widget>[
    FeedScreen(state: _feedState),
    ProductScreen(state: _productState),
    ChatScreen(state: _chatState),
    SettingsScreen(state: _settingsState),
  ];

  int _selectedIndex = 0;

  @override
  void dispose() {
    _feedState.dispose();
    _productState.dispose();
    _chatState.dispose();
    _settingsState.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: IndexedStack(index: _selectedIndex, children: _screens),
      bottomNavigationBar: Listen<listen.Listenable>(
        listenable: listen.Listenable.merge(<listen.Listenable>[
          _productState,
          _chatState,
        ]),
        builder: (BuildContext context, listen.Listenable _) {
          return NavigationBar(
            selectedIndex: _selectedIndex,
            onDestinationSelected: (int index) {
              setState(() => _selectedIndex = index);
            },
            destinations: <Widget>[
              const NavigationDestination(
                icon: Icon(Icons.dynamic_feed_outlined),
                selectedIcon: Icon(Icons.dynamic_feed),
                label: 'Feed',
              ),
              NavigationDestination(
                icon: Badge(
                  isLabelVisible: _productState.cartCount > 0,
                  label: Text('${_productState.cartCount}'),
                  child: const Icon(Icons.storefront_outlined),
                ),
                selectedIcon: Badge(
                  isLabelVisible: _productState.cartCount > 0,
                  label: Text('${_productState.cartCount}'),
                  child: const Icon(Icons.storefront),
                ),
                label: 'Product',
              ),
              NavigationDestination(
                icon: Badge(
                  isLabelVisible: _chatState.totalUnread > 0,
                  label: Text('${_chatState.totalUnread}'),
                  child: const Icon(Icons.chat_bubble_outline),
                ),
                selectedIcon: Badge(
                  isLabelVisible: _chatState.totalUnread > 0,
                  label: Text('${_chatState.totalUnread}'),
                  child: const Icon(Icons.chat_bubble),
                ),
                label: 'Chat',
              ),
              const NavigationDestination(
                icon: Icon(Icons.settings_outlined),
                selectedIcon: Icon(Icons.settings),
                label: 'Settings',
              ),
            ],
          );
        },
      ),
    );
  }
}

How the pieces fit together

  1. IndexedStack keeps every screen’s widget tree alive. When you switch from Feed to Chat and back, your scroll position, text field content, and State objects are all preserved.

  2. Listenable.merge combines _productState and _chatState into one Listenable. When either notifier fires, the Listen widget rebuilds the NavigationBar, so the cart count and unread badges update reactively.

  3. Constructor injection — each screen receives its state through the constructor. There is no service locator, no InheritedWidget, no Provider package. The data flow is explicit and traceable.

  4. Ownership and disposal_HomePageState creates all four notifiers in its fields, passes them to screens, and disposes them in dispose(). No notifier escapes its lifecycle.

6. Entry Point

// lib/main.dart

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

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Listen App',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
      ),
      home: const HomePage(),
    );
  }
}

7. Verify

flutter analyze   # zero issues
flutter test      # all tests pass
flutter run       # launch the app

Tests

The test file covers both widget and unit testing:

// test/widget_test.dart

import 'package:flutter_test/flutter_test.dart';
import 'package:listen_app/main.dart';
import 'package:listen_app/states/chat_state.dart';
import 'package:listen_app/states/product_state.dart';
import 'package:listen_app/states/settings_state.dart';

void main() {
  testWidgets('Bottom navigation switches between the four screens',
      (WidgetTester tester) async {
    await tester.pumpWidget(const ListenApp());

    expect(find.text('Feed'), findsWidgets);

    await tester.tap(find.text('Product'));
    await tester.pumpAndSettle();
    expect(find.text('Products'), findsOneWidget);

    await tester.tap(find.text('Chat'));
    await tester.pumpAndSettle();
    expect(find.text('Design Team'), findsWidgets);

    await tester.tap(find.text('Settings'));
    await tester.pumpAndSettle();
    expect(find.text('Notifications'), findsOneWidget);
  });

  test('ChatState sends messages and tracks unread count', () {
    final ChatState state = ChatState();
    final int initialUnread = state.unreadFor(state.selected);
    expect(initialUnread, greaterThan(0));

    state.select(0);
    expect(state.unreadFor(state.selected), 0);

    state.sendMessage('Hello');
    expect(state.selected.messages.last, 'Hello');
  });

  test('ProductState toggles cart membership', () {
    final ProductState state = ProductState();
    expect(state.cartCount, 0);

    state.toggleCart(state.products.first);
    expect(state.cartCount, 1);
    expect(state.cartTotal, greaterThan(0));

    state.toggleCart(state.products.first);
    expect(state.cartCount, 0);
  });

  test('SettingsState persists toggle values', () {
    final SettingsState state = SettingsState();
    expect(state.notifications, isTrue);

    state.setNotifications(false);
    expect(state.notifications, isFalse);
  });
}

Project Structure

lib/
  main.dart
  home_page.dart
  widgets/
    listen.dart
  states/
    feed_state.dart
    product_state.dart
    chat_state.dart
    settings_state.dart
  screens/
    feed_screen.dart
    product_screen.dart
    chat_screen.dart
    settings_screen.dart

Summary

ConceptImplementation
State managementpackage:listen (ChangeNotifier subclasses)
Widget bridgeCustom Listen<T extends listen.Listenable> widget
Tab persistenceIndexedStack keeps all screen trees alive
Cross-screen reactivityListenable.merge on the NavigationBar for badges
OwnershipHomePage owns, injects, and disposes all notifiers
Data flowExplicit constructor injection — no DI framework needed

The result is a clean, dependency-light architecture where each screen’s state is fully independent, type-safe, and easy to test in isolation.