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
-
IndexedStackkeeps every screen’s widget tree alive. When you switch from Feed to Chat and back, your scroll position, text field content, andStateobjects are all preserved. -
Listenable.mergecombines_productStateand_chatStateinto oneListenable. When either notifier fires, theListenwidget rebuilds theNavigationBar, so the cart count and unread badges update reactively. -
Constructor injection — each screen receives its state through the constructor. There is no service locator, no
InheritedWidget, noProviderpackage. The data flow is explicit and traceable. -
Ownership and disposal —
_HomePageStatecreates all four notifiers in its fields, passes them to screens, and disposes them indispose(). 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
| Concept | Implementation |
|---|---|
| State management | package:listen (ChangeNotifier subclasses) |
| Widget bridge | Custom Listen<T extends listen.Listenable> widget |
| Tab persistence | IndexedStack keeps all screen trees alive |
| Cross-screen reactivity | Listenable.merge on the NavigationBar for badges |
| Ownership | HomePage owns, injects, and disposes all notifiers |
| Data flow | Explicit 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.