A GoRouter sample demonstrating the back button issues when not using nested routes.
Created with <3 with dartpad.dev.
A GoRouter sample demonstrating the back button issues when not using nested routes.
Created with <3 with dartpad.dev.
// Copyright 2013 The Flutter Authors. All rights reserved. | |
// Use of this source code is governed by a BSD-style license that can be | |
// found in the LICENSE file. | |
import 'dart:math' as math; | |
import 'package:flutter/material.dart'; | |
import 'package:go_router/go_router.dart'; | |
import 'package:english_words/english_words.dart'; | |
void main() { | |
runApp(MusicAppDemo()); | |
} | |
class MusicAppDemo extends StatelessWidget { | |
MusicAppDemo({super.key}); | |
final MusicDatabase database = MusicDatabase.mock(); | |
final GoRouter _router = GoRouter( | |
initialLocation: '/library', | |
routes: <RouteBase>[ | |
ShellRoute( | |
builder: (BuildContext context, GoRouterState state, Widget child) { | |
return MusicAppShell( | |
currentIndex: switch (state.uri.path) { | |
var p when p.startsWith('/recents') => 1, | |
var p when p.startsWith('/search') => 2, | |
_ => 0, | |
}, | |
child: child, | |
); | |
}, | |
routes: <RouteBase>[ | |
GoRoute( | |
path: '/library', | |
pageBuilder: (context, state) { | |
return FadeTransitionPage( | |
child: const LibraryScreen(), | |
key: state.pageKey, | |
); | |
}, | |
routes: <RouteBase>[ | |
// Comment out these lines to toggle back button issue | |
// GoRoute( | |
// path: '/library/album/:albumId', | |
// builder: (BuildContext context, GoRouterState state) { | |
// return AlbumScreen( | |
// albumId: state.pathParameters['albumId'], | |
// ); | |
// }, | |
// routes: [ | |
// GoRoute( | |
// path: '/library/song/:songId', | |
// // Display on the root Navigator | |
// builder: (BuildContext context, GoRouterState state) { | |
// return SongScreen( | |
// songId: state.pathParameters['songId']!, | |
// ); | |
// }, | |
// ), | |
// ], | |
// ), | |
], | |
), | |
GoRoute( | |
path: '/library/album/:albumId', | |
builder: (BuildContext context, GoRouterState state) { | |
return AlbumScreen( | |
albumId: state.pathParameters['albumId'], | |
); | |
}, | |
), | |
GoRoute( | |
path: '/library/song/:songId', | |
// Display on the root Navigator | |
builder: (BuildContext context, GoRouterState state) { | |
return SongScreen( | |
songId: state.pathParameters['songId']!, | |
); | |
}, | |
), | |
GoRoute( | |
path: '/recents', | |
pageBuilder: (context, state) { | |
return FadeTransitionPage( | |
child: const RecentlyPlayedScreen(), | |
key: state.pageKey, | |
); | |
}, | |
routes: <RouteBase>[ | |
GoRoute( | |
path: 'song/:songId', | |
// Display on the root Navigator | |
builder: (BuildContext context, GoRouterState state) { | |
return SongScreen( | |
songId: state.pathParameters['songId']!, | |
); | |
}, | |
), | |
], | |
), | |
GoRoute( | |
path: '/search', | |
pageBuilder: (context, state) { | |
final query = state.uri.queryParameters['q'] ?? ''; | |
return FadeTransitionPage( | |
child: SearchScreen( | |
query: query, | |
), | |
key: state.pageKey, | |
); | |
}, | |
), | |
], | |
), | |
], | |
); | |
@override | |
Widget build(BuildContext context) { | |
return MaterialApp.router( | |
title: 'Music app', | |
theme: ThemeData( | |
colorSchemeSeed: Colors.pink, | |
useMaterial3: true, | |
), | |
routerConfig: _router, | |
builder: (context, child) { | |
return MusicDatabaseScope( | |
state: database, | |
child: child!, | |
); | |
}, | |
); | |
} | |
} | |
class MusicAppShell extends StatelessWidget { | |
final Widget child; | |
final int currentIndex; | |
const MusicAppShell({ | |
super.key, | |
required this.child, | |
required this.currentIndex, | |
}); | |
@override | |
Widget build(BuildContext context) { | |
return Scaffold( | |
body: child, | |
bottomNavigationBar: BottomNavigationBar( | |
items: const <BottomNavigationBarItem>[ | |
BottomNavigationBarItem( | |
icon: Icon(Icons.my_library_music_rounded), | |
label: 'Library', | |
), | |
BottomNavigationBarItem( | |
icon: Icon(Icons.timelapse), | |
label: 'Recently Played', | |
), | |
BottomNavigationBarItem( | |
icon: Icon(Icons.search), | |
label: 'Search', | |
), | |
], | |
currentIndex: currentIndex, | |
onTap: (int idx) => _onItemTapped(idx, context), | |
), | |
); | |
} | |
void _onItemTapped(int index, BuildContext context) { | |
switch (index) { | |
case 1: | |
GoRouter.of(context).go('/recents'); | |
break; | |
case 2: | |
GoRouter.of(context).go('/search'); | |
break; | |
case 0: | |
default: | |
GoRouter.of(context).go('/library'); | |
break; | |
} | |
} | |
} | |
class LibraryScreen extends StatelessWidget { | |
const LibraryScreen({ | |
super.key, | |
}); | |
@override | |
Widget build(BuildContext context) { | |
final database = MusicDatabase.of(context); | |
return Scaffold( | |
appBar: AppBar( | |
title: const Text('Library'), | |
), | |
body: ListView.builder( | |
itemBuilder: (context, albumId) { | |
final album = database.albums[albumId]; | |
return AlbumTile( | |
album: album, | |
onTap: () { | |
GoRouter.of(context).go('/library/album/$albumId'); | |
}, | |
); | |
}, | |
itemCount: database.albums.length, | |
), | |
); | |
} | |
} | |
class RecentlyPlayedScreen extends StatelessWidget { | |
const RecentlyPlayedScreen({Key? key}) : super(key: key); | |
@override | |
Widget build(BuildContext context) { | |
final database = MusicDatabase.of(context); | |
final songs = database.recentlyPlayed; | |
return Scaffold( | |
appBar: AppBar( | |
title: const Text('Recently Played'), | |
), | |
body: ListView.builder( | |
itemBuilder: (context, index) { | |
final song = songs[index]; | |
final albumIdInt = int.tryParse(song.albumId)!; | |
final album = database.albums[albumIdInt]; | |
return SongTile( | |
album: album, | |
song: song, | |
onTap: () { | |
GoRouter.of(context).go('/recents/song/${song.fullId}'); | |
}, | |
); | |
}, | |
itemCount: songs.length, | |
), | |
); | |
} | |
} | |
class SearchScreen extends StatefulWidget { | |
final String query; | |
const SearchScreen({Key? key, required this.query}) : super(key: key); | |
@override | |
State<SearchScreen> createState() => _SearchScreenState(); | |
} | |
class _SearchScreenState extends State<SearchScreen> { | |
String? _currentQuery; | |
@override | |
Widget build(BuildContext context) { | |
final database = MusicDatabase.of(context); | |
final songs = database.search(widget.query); | |
return Scaffold( | |
appBar: AppBar( | |
title: const Text('Search'), | |
), | |
body: Column( | |
children: [ | |
Padding( | |
padding: const EdgeInsets.all(12.0), | |
child: TextField( | |
decoration: const InputDecoration( | |
hintText: 'Search...', | |
border: OutlineInputBorder(), | |
), | |
onChanged: (String? newSearch) { | |
_currentQuery = newSearch; | |
}, | |
onEditingComplete: () { | |
GoRouter.of(context).go( | |
'/search?q=$_currentQuery', | |
); | |
}, | |
), | |
), | |
Expanded( | |
child: ListView.builder( | |
itemBuilder: (context, index) { | |
final song = songs[index]; | |
return SongTile( | |
album: database.albums[int.tryParse(song.albumId)!], | |
song: song, | |
onTap: () { | |
GoRouter.of(context).go( | |
'/library/album/${song.albumId}/song/${song.fullId}'); | |
}, | |
); | |
}, | |
itemCount: songs.length, | |
), | |
), | |
], | |
), | |
); | |
} | |
} | |
class AlbumScreen extends StatelessWidget { | |
final String? albumId; | |
const AlbumScreen({ | |
required this.albumId, | |
Key? key, | |
}) : super(key: key); | |
@override | |
Widget build(BuildContext context) { | |
final database = MusicDatabase.of(context); | |
final albumIdInt = int.tryParse(albumId ?? ''); | |
final album = database.albums[albumIdInt!]; | |
return Scaffold( | |
appBar: AppBar( | |
title: Text('Album - ${album.title}'), | |
), | |
body: Center( | |
child: Column( | |
children: [ | |
Row( | |
children: [ | |
SizedBox( | |
width: 200, | |
height: 200, | |
child: Container( | |
color: album.color, | |
margin: const EdgeInsets.all(8), | |
), | |
), | |
Column( | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: [ | |
Text( | |
album.title, | |
style: Theme.of(context).textTheme.headlineMedium, | |
), | |
Text( | |
album.artist, | |
style: Theme.of(context).textTheme.titleMedium, | |
), | |
], | |
), | |
], | |
), | |
Expanded( | |
child: ListView.builder( | |
itemBuilder: (context, index) { | |
final song = album.songs[index]; | |
return ListTile( | |
title: Text(song.title), | |
leading: SizedBox( | |
width: 50, | |
height: 50, | |
child: Container( | |
color: album.color, | |
margin: const EdgeInsets.all(8), | |
), | |
), | |
trailing: SongDuration( | |
duration: song.duration, | |
), | |
onTap: () { | |
GoRouter.of(context) | |
.go('/library/album/$albumId/song/${song.fullId}'); | |
}, | |
); | |
}, | |
itemCount: album.songs.length, | |
), | |
), | |
], | |
), | |
), | |
); | |
} | |
} | |
class SongScreen extends StatelessWidget { | |
final String songId; | |
const SongScreen({ | |
Key? key, | |
required this.songId, | |
}) : super(key: key); | |
@override | |
Widget build(BuildContext context) { | |
final database = MusicDatabase.of(context); | |
final song = database.getSongById(songId); | |
final albumIdInt = int.tryParse(song.albumId); | |
final album = database.albums[albumIdInt!]; | |
return Scaffold( | |
appBar: AppBar( | |
title: Text('Song - ${song.title}'), | |
), | |
body: Column( | |
children: [ | |
Row( | |
children: [ | |
SizedBox( | |
width: 300, | |
height: 300, | |
child: Container( | |
color: album.color, | |
margin: const EdgeInsets.all(8), | |
), | |
), | |
Padding( | |
padding: const EdgeInsets.all(16.0), | |
child: Column( | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: [ | |
Text( | |
song.title, | |
style: Theme.of(context).textTheme.displayMedium, | |
), | |
Text( | |
album.title, | |
style: Theme.of(context).textTheme.titleMedium, | |
), | |
], | |
), | |
) | |
], | |
) | |
], | |
), | |
); | |
} | |
} | |
class MusicDatabase { | |
final List<Album> albums; | |
final List<Song> recentlyPlayed; | |
final Map<String, Song> _allSongs = {}; | |
MusicDatabase(this.albums, this.recentlyPlayed) { | |
_populateAllSongs(); | |
} | |
factory MusicDatabase.mock() { | |
final albums = _mockAlbums().toList(); | |
final recentlyPlayed = _mockRecentlyPlayed(albums).toList(); | |
return MusicDatabase(albums, recentlyPlayed); | |
} | |
Song getSongById(String songId) { | |
if (_allSongs.containsKey(songId)) { | |
return _allSongs[songId]!; | |
} | |
throw ('No song with ID $songId found.'); | |
} | |
List<Song> search(String searchString) { | |
final songs = <Song>[]; | |
for (var song in _allSongs.values) { | |
final album = albums[int.tryParse(song.albumId)!]; | |
if (song.title.contains(searchString) || | |
album.title.contains(searchString)) { | |
songs.add(song); | |
} | |
} | |
return songs; | |
} | |
void _populateAllSongs() { | |
for (var album in albums) { | |
for (var song in album.songs) { | |
_allSongs[song.fullId] = song; | |
} | |
} | |
} | |
static MusicDatabase of(BuildContext context) { | |
final routeStateScope = | |
context.dependOnInheritedWidgetOfExactType<MusicDatabaseScope>(); | |
if (routeStateScope == null) throw ('No RouteState in scope!'); | |
return routeStateScope.state; | |
} | |
static Iterable<Album> _mockAlbums() sync* { | |
for (var i = 0; i < Colors.primaries.length; i++) { | |
final color = Colors.primaries[i]; | |
final title = WordPair.random().toString(); | |
final artist = WordPair.random().toString(); | |
final songs = <Song>[]; | |
for (var j = 0; j < 12; j++) { | |
final minutes = math.Random().nextInt(3) + 3; | |
final seconds = math.Random().nextInt(60); | |
final title = WordPair.random(); | |
final duration = Duration(minutes: minutes, seconds: seconds); | |
final song = Song('$j', '$i', '$title', duration); | |
songs.add(song); | |
} | |
yield Album('$i', title, artist, color, songs); | |
} | |
} | |
static Iterable<Song> _mockRecentlyPlayed(List<Album> albums) sync* { | |
for (var album in albums) { | |
final songIndex = math.Random().nextInt(album.songs.length); | |
yield album.songs[songIndex]; | |
} | |
} | |
} | |
class MusicDatabaseScope extends InheritedWidget { | |
final MusicDatabase state; | |
const MusicDatabaseScope({ | |
required this.state, | |
required Widget child, | |
Key? key, | |
}) : super(child: child, key: key); | |
@override | |
bool updateShouldNotify(covariant InheritedWidget oldWidget) { | |
return oldWidget is MusicDatabaseScope && state != oldWidget.state; | |
} | |
} | |
class Album { | |
final String id; | |
final String title; | |
final String artist; | |
final Color color; | |
final List<Song> songs; | |
Album(this.id, this.title, this.artist, this.color, this.songs); | |
} | |
class Song { | |
final String id; | |
final String albumId; | |
final String title; | |
final Duration duration; | |
Song(this.id, this.albumId, this.title, this.duration); | |
String get fullId => '$albumId-$id'; | |
} | |
class AlbumTile extends StatelessWidget { | |
final Album album; | |
final VoidCallback? onTap; | |
const AlbumTile({Key? key, required this.album, this.onTap}) | |
: super(key: key); | |
@override | |
Widget build(BuildContext context) { | |
return ListTile( | |
leading: SizedBox( | |
width: 50, | |
height: 50, | |
child: Container( | |
color: album.color, | |
), | |
), | |
title: Text(album.title), | |
subtitle: Text(album.artist), | |
onTap: onTap, | |
); | |
} | |
} | |
class SongTile extends StatelessWidget { | |
final Album album; | |
final Song song; | |
final VoidCallback? onTap; | |
const SongTile( | |
{Key? key, required this.album, required this.song, this.onTap}) | |
: super(key: key); | |
@override | |
Widget build(BuildContext context) { | |
return ListTile( | |
leading: SizedBox( | |
width: 50, | |
height: 50, | |
child: Container( | |
color: album.color, | |
margin: const EdgeInsets.all(8), | |
), | |
), | |
title: Text(song.title), | |
trailing: SongDuration( | |
duration: song.duration, | |
), | |
onTap: onTap, | |
); | |
} | |
} | |
class SongDuration extends StatelessWidget { | |
final Duration duration; | |
const SongDuration({ | |
required this.duration, | |
Key? key, | |
}) : super(key: key); | |
@override | |
Widget build(BuildContext context) { | |
return Text( | |
'${duration.inMinutes.toString().padLeft(2, '0')}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}'); | |
} | |
} | |
/// A page that fades in an out. | |
class FadeTransitionPage extends CustomTransitionPage<void> { | |
/// Creates a [FadeTransitionPage]. | |
FadeTransitionPage({ | |
required LocalKey key, | |
required Widget child, | |
}) : super( | |
key: key, | |
transitionsBuilder: (BuildContext context, | |
Animation<double> animation, | |
Animation<double> secondaryAnimation, | |
Widget child) => | |
FadeTransition( | |
opacity: animation.drive(_curveTween), | |
child: child, | |
), | |
child: child); | |
static final CurveTween _curveTween = CurveTween(curve: Curves.easeIn); | |
} |