Skip to content

Airplane Entertainment System

The Airplane Entertainment System simulates an in-flight entertainment system that provides mock flight progress updates, weather, and an audio player.

Screenshot of the Airplane Entertainment System.

The source code for this project is available on GitHub. To view the live demo, click here.

Architecture

The Airplane Entertainment System was built using layered architecture. In the interest of a well organized project, the data, repository, and presentation layers have been separated out into their own packages. We will take an in-depth look at the flight tracker feature of the app to see how it was implemented using this architecture.

Flight Tracker

Screenshot of the flight tracker.

The flight tracker simulates a flight between Newark and New York City, providing updates on the flight’s progress every minute. The flight is scheduled to take off at 1:00 PM and is estimated to take 45 minutes, but the simulated delays can change the arrival time. For simplicity, a timestamp is included in the API response that begins at 1:00 PM and is incremented by one minute for each update.

Flight API Client

The Flight API Client emits of stream of mock flight data every minute to its listeners. In our layered architecture, the Flight API Client is part of the data layer. The API is designed to provide basic flight information so that any information that is derived from this data, like the remaining flight time, can be calculated in a different layer.

Flight Information Repository

The Flight Information Repository is responsible for taking the raw data provided by the Flight API Client, applying domain business logic to the data, then providing that data to the presentation layer.

BehaviorSubject<FlightInformation>? _flightController;
/// Retrieves the flight information.
Stream<FlightInformation> get flightInformation {
if (_flightController == null) {
_flightController = BehaviorSubject();
_flightApiClient.flightInformation.listen((flightInformation) {
_flightController!.add(flightInformation);
});
}
return _flightController!.stream;
}

Flight Tracking View

The Flight Tracking view consists of the UI components to display the flight information. The FlightTrackingBloc updates the UI with the latest information from the Flight Information Repository. This keeps all of the business logic, like fetching the data and calculating the remaining flight time, outside of the widget.

The Airplane Entertainment System uses bottom and side navigation bars to switch between the different tabs of the app. To maintain each tab’s state, we use GoRouter’s StatefulShellRoute. By using type-safe routes, we can setup our navigation structure in routes.dart.

@TypedStatefulShellRoute<HomeScreenRouteData>(
branches: [
TypedStatefulShellBranch<OverviewPageBranchData>(
routes: [
TypedGoRoute<OverviewPageRouteData>(
name: 'overview',
path: '/overview',
),
],
),
TypedStatefulShellBranch<MusicPageBranchData>(
routes: [
TypedGoRoute<MusicPlayerPageRouteData>(
name: 'music',
path: '/music',
),
],
),
],
)

The HomeScreenRouteData class is the route to our AirplaneEntertainmentSystemScreen widget, which is the container for our navigation bars and content.

@immutable
class HomeScreenRouteData extends StatefulShellRouteData {
const HomeScreenRouteData();
@override
Widget builder(
BuildContext context,
GoRouterState state,
StatefulNavigationShell navigationShell,
) =>
navigationShell;
static Widget $navigatorContainerBuilder(
BuildContext context,
StatefulNavigationShell navigationShell,
List<Widget> children,
) {
return AirplaneEntertainmentSystemScreen(
navigationShell: navigationShell,
children: children,
);
}
}

OverviewPageBranchData and MusicPageBranchData classes represent your branches. OverviewPageRouteData and MusicPlayerPageRouteData classes represent the routes within the branches. Override GoRouteData’s build method to return the widget to display for the route.

@immutable
class OverviewPageBranchData extends StatefulShellBranchData {
const OverviewPageBranchData();
}
@immutable
class OverviewPageRouteData extends GoRouteData {
const OverviewPageRouteData();
@override
Widget build(BuildContext context, GoRouterState state) =>
const OverviewPage();
}
@immutable
class MusicPageBranchData extends StatefulShellBranchData {
const MusicPageBranchData();
}
@immutable
class MusicPlayerPageRouteData extends GoRouteData {
const MusicPlayerPageRouteData();
@override
Widget build(BuildContext context, GoRouterState state) =>
const MusicPlayerPage();
}

StatefulShellRoute Transition Animations

Transition animation when switching between tabs.

To add custom transition animations to your routes that are in the same navigation stack, override the GoRouteData’s pageBuilder method. Your custom animation will then be used anytime you navigate to that route.

However, when using a StatefulShellRoute, each tab has a separate Navigator for each branch. To add a transition animation when navigating between routes that are on different branches, like when switching between tabs, you must provide a custom navigatorContainerBuilder to provide the StatefulNavigationShell and the children (Navigators) that are in your shell route to your “container” widget. The StatefulNavigationShell provides the current index of the child (Navigator) that is selected and a method to navigate to a specific child. Once you have this data, adding transition animations using implicit animation widgets like AnimatedSlide is straightforward.

In airplane_entertainment_system.dart, we create a widget that manages the transition animations between the children in the StatefulShellRoute.

class _AnimatedBranchContainer extends StatelessWidget {
const _AnimatedBranchContainer({
required this.currentIndex,
required this.children,
});
final int currentIndex;
final List<Widget> children;
@override
Widget build(BuildContext context) {
final isSmall = AesLayout.of(context) == AesLayoutData.small;
final axis = isSmall ? Axis.horizontal : Axis.vertical;
return Stack(
children: children.mapIndexed(
(int index, Widget navigator) {
return AnimatedSlide(
duration: const Duration(milliseconds: 600),
curve: index == currentIndex ? Curves.easeOut : Curves.easeInOut,
offset: Offset(
axis == Axis.horizontal
? index == currentIndex
? 0
: 0.25
: 0,
axis == Axis.vertical
? index == currentIndex
? 0
: 0.25
: 0,
),
child: AnimatedOpacity(
opacity: index == currentIndex ? 1 : 0,
duration: const Duration(milliseconds: 300),
child: IgnorePointer(
ignoring: index != currentIndex,
child: TickerMode(
enabled: index == currentIndex,
child: navigator,
),
),
),
);
},
).toList(),
);
}
}

The _AnimatedBranchContainer widget is a custom implementation of the StatefulShellRoute.indexedStack constructor. We must provide our own Stack widget to contain the children and manually update the index of the children within the Stack when the route changes. Since implicit animations automatically update when any of their properties change, we don’t have to worry about creating custom animation objects or managing their state. Wrapping our navigator widget in a TickerMode widget ensures that any animation tickers for the non-selected navigator are disabled.

To switch between tabs, simply call the goBranch method on the StatefulNavigationShell with the index of the tab you want to navigate to.

navigationShell.goBranch(
index,
initialLocation:
index == navigationShell.currentIndex,
);