diff --git a/.github/workflows/aes_ui.yaml b/.github/workflows/aes_ui.yaml new file mode 100644 index 0000000..a000b2b --- /dev/null +++ b/.github/workflows/aes_ui.yaml @@ -0,0 +1,21 @@ +name: aes_ui + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + paths: + - "packages/aes_ui/**" + - ".github/workflows/aes_ui.yaml" + branches: + - main + +jobs: + build: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1 + with: + flutter_channel: stable + working_directory: packages/aes_ui + coverage_excludes: "**/*.g.dart" diff --git a/lib/airplane_entertainment_system/view/airplane_entertainment_system_screen.dart b/lib/airplane_entertainment_system/view/airplane_entertainment_system_screen.dart index dfd4837..1cc9f6e 100644 --- a/lib/airplane_entertainment_system/view/airplane_entertainment_system_screen.dart +++ b/lib/airplane_entertainment_system/view/airplane_entertainment_system_screen.dart @@ -1,3 +1,4 @@ +import 'package:aes_ui/aes_ui.dart'; import 'package:airplane_entertainment_system/airplane_entertainment_system/airplane_entertainment_system.dart'; import 'package:airplane_entertainment_system/music_player/music_player.dart'; import 'package:airplane_entertainment_system/overview/overview.dart'; @@ -17,6 +18,19 @@ class _AirplaneEntertainmentSystemScreenState @override Widget build(BuildContext context) { + final layout = AesLayout.of(context); + + const destinations = [ + Destination( + Icon(Icons.airplanemode_active_outlined), + 'Home', + ), + Destination( + Icon(Icons.music_note), + 'Music', + ), + ]; + return Scaffold( body: LayoutBuilder( builder: (context, constraints) { @@ -33,14 +47,16 @@ class _AirplaneEntertainmentSystemScreenState Expanded( child: Row( children: [ - LeftSideNavigationRail( - selectedIndex: _currentPage, - onOptionSelected: (value) { - setState(() { - _currentPage = value; - }); - }, - ), + if (layout == AesLayoutData.large) + AesNavigationRail( + destinations: destinations, + selectedIndex: _currentPage, + onDestinationSelected: (value) { + setState(() { + _currentPage = value; + }); + }, + ), Expanded( child: _ContentPageView( pageSize: Size( @@ -76,6 +92,17 @@ class _AirplaneEntertainmentSystemScreenState ); }, ), + bottomNavigationBar: (layout == AesLayoutData.small) + ? AesBottomNavigationBar( + destinations: destinations, + selectedIndex: _currentPage, + onDestinationSelected: (value) { + setState(() { + _currentPage = value; + }); + }, + ) + : null, ); } } diff --git a/lib/airplane_entertainment_system/widgets/left_side_navigation_rail.dart b/lib/airplane_entertainment_system/widgets/left_side_navigation_rail.dart deleted file mode 100644 index 1c6eb2f..0000000 --- a/lib/airplane_entertainment_system/widgets/left_side_navigation_rail.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:flutter/material.dart'; - -class LeftSideNavigationRail extends StatelessWidget { - const LeftSideNavigationRail({ - required this.selectedIndex, - super.key, - this.onOptionSelected, - }); - - final int selectedIndex; - final ValueChanged? onOptionSelected; - - @override - Widget build(BuildContext context) { - return ClipPath( - clipper: CustomRectangularShape(), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 80, maxHeight: 550), - child: Theme( - data: ThemeData( - navigationRailTheme: const NavigationRailThemeData( - labelType: NavigationRailLabelType.none, - selectedIconTheme: IconThemeData(size: 36, color: Colors.red), - unselectedIconTheme: IconThemeData(size: 36), - groupAlignment: 0, - ), - ), - child: Center( - child: NavigationRail( - selectedIndex: selectedIndex, - onDestinationSelected: onOptionSelected, - destinations: const [ - NavigationRailDestination( - icon: Icon(Icons.airplanemode_active_outlined), - label: Text('airplanemode_active'), - ), - NavigationRailDestination( - icon: Icon(Icons.headphones), - label: Text('headphones'), - ), - ], - ), - ), - ), - ), - ); - } -} - -@visibleForTesting -class CustomRectangularShape extends CustomClipper { - @override - Path getClip(Size size) { - final path = Path() - ..lineTo(size.width - 10, 50) - ..quadraticBezierTo(size.width, 55, size.width, 70) - ..lineTo(size.width, size.height - 70) - ..quadraticBezierTo( - size.width, - size.height - 55, - size.width - 10, - size.height - 50, - ) - ..lineTo(0, size.height); - - return path; - } - - @override - bool shouldReclip(CustomClipper oldClipper) => false; -} diff --git a/lib/airplane_entertainment_system/widgets/widgets.dart b/lib/airplane_entertainment_system/widgets/widgets.dart index 6b471fa..8a0314a 100644 --- a/lib/airplane_entertainment_system/widgets/widgets.dart +++ b/lib/airplane_entertainment_system/widgets/widgets.dart @@ -1,4 +1,3 @@ export 'clouds.dart'; -export 'left_side_navigation_rail.dart'; export 'system_background.dart'; export 'top_button_bar.dart'; diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 2c2c192..46a60d5 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -1,3 +1,4 @@ +import 'package:aes_ui/aes_ui.dart'; import 'package:airplane_entertainment_system/airplane_entertainment_system/airplane_entertainment_system.dart'; import 'package:airplane_entertainment_system/l10n/l10n.dart'; import 'package:flutter/material.dart'; @@ -7,16 +8,13 @@ class App extends StatelessWidget { @override Widget build(BuildContext context) { - return MaterialApp( - theme: ThemeData( - appBarTheme: AppBarTheme( - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - ), - useMaterial3: true, + return AesLayout( + child: MaterialApp( + theme: const AesTheme().themeData, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const AirplaneEntertainmentSystemScreen(), ), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: const AirplaneEntertainmentSystemScreen(), ); } } diff --git a/lib/music_player/view/music_player_page.dart b/lib/music_player/view/music_player_page.dart index f8ee4f5..8f7482d 100644 --- a/lib/music_player/view/music_player_page.dart +++ b/lib/music_player/view/music_player_page.dart @@ -1,3 +1,4 @@ +import 'package:aes_ui/aes_ui.dart'; import 'package:airplane_entertainment_system/l10n/l10n.dart'; import 'package:airplane_entertainment_system/music_player/music_player.dart'; import 'package:flutter/material.dart'; @@ -216,16 +217,13 @@ class _MusicMenuHeader extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = context.l10n; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.goodVibes, - style: const TextStyle( - fontSize: 60, - fontWeight: FontWeight.w600, - height: 1, - ), + style: AesTextStyles.headlineLarge, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 10), diff --git a/lib/overview/view/overview_page.dart b/lib/overview/view/overview_page.dart index c3f8dab..10b9923 100644 --- a/lib/overview/view/overview_page.dart +++ b/lib/overview/view/overview_page.dart @@ -1,3 +1,4 @@ +import 'package:aes_ui/aes_ui.dart'; import 'package:airplane_entertainment_system/l10n/l10n.dart'; import 'package:airplane_entertainment_system/overview/overview.dart'; import 'package:flutter/material.dart'; @@ -5,48 +6,91 @@ import 'package:flutter/material.dart'; class OverviewPage extends StatelessWidget { const OverviewPage({super.key}); + @override + Widget build(BuildContext context) { + final layout = AesLayout.of(context); + + return switch (layout) { + AesLayoutData.small => const _SmallOverviewPage(), + AesLayoutData.large => const _LargeOverviewPage(), + }; + } +} + +class _SmallOverviewPage extends StatelessWidget { + const _SmallOverviewPage(); + @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric( vertical: 20, ), - child: LayoutBuilder( - builder: (context, constraints) { - final isWide = constraints.maxWidth > 800; + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(width: 60), + Expanded( + flex: 4, + child: ListView( + padding: const EdgeInsets.only(right: 80), + children: const [ + WelcomeCopy(), + SizedBox(height: 40), + FlightTrackingCard(), + SizedBox(height: 20), + WeatherCard(), + SizedBox(height: 20), + MusicCard(), + SizedBox(height: 20), + MovieCard(), + ], + ), + ), + ], + ), + ); + } +} - return Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (isWide) - const Expanded( - flex: 5, - child: Padding( - padding: EdgeInsets.only(left: 80), - child: AirplaneImage(), - ), - ), - const SizedBox(width: 60), - Expanded( - flex: 4, - child: ListView( - padding: const EdgeInsets.only(right: 80), - children: const [ - WelcomeCopy(), - SizedBox(height: 40), - FlightTrackingCard(), - SizedBox(height: 20), - WeatherCard(), - SizedBox(height: 20), - MusicCard(), - SizedBox(height: 20), - MovieCard(), - ], - ), - ), - ], - ); - }, +class _LargeOverviewPage extends StatelessWidget { + const _LargeOverviewPage(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + vertical: 20, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Expanded( + flex: 5, + child: Padding( + padding: EdgeInsets.only(left: 80), + child: AirplaneImage(), + ), + ), + const SizedBox(width: 60), + Expanded( + flex: 4, + child: ListView( + padding: const EdgeInsets.only(right: 80), + children: const [ + WelcomeCopy(), + SizedBox(height: 40), + FlightTrackingCard(), + SizedBox(height: 20), + WeatherCard(), + SizedBox(height: 20), + MusicCard(), + SizedBox(height: 20), + MovieCard(), + ], + ), + ), + ], ), ); } @@ -58,6 +102,7 @@ class WelcomeCopy extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = context.l10n; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -65,11 +110,7 @@ class WelcomeCopy extends StatelessWidget { Text( l10n.welcomeMessage, maxLines: 2, - style: const TextStyle( - fontSize: 60, - fontWeight: FontWeight.w600, - height: 1, - ), + style: AesTextStyles.headlineLarge, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 10), diff --git a/lib/overview/widgets/flight_tracking_card.dart b/lib/overview/widgets/flight_tracking_card.dart index 56e2388..3519757 100644 --- a/lib/overview/widgets/flight_tracking_card.dart +++ b/lib/overview/widgets/flight_tracking_card.dart @@ -1,3 +1,4 @@ +import 'package:aes_ui/aes_ui.dart'; import 'package:airplane_entertainment_system/l10n/l10n.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; @@ -130,18 +131,11 @@ class _DepartureOrArrivalTime extends StatelessWidget { children: [ Text( time, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), + style: AesTextStyles.titleLarge, ), Text( code, - style: const TextStyle( - color: Colors.grey, - fontSize: 14, - fontWeight: FontWeight.w600, - ), + style: AesTextStyles.labelLarge, ), ], ); @@ -154,6 +148,7 @@ class _RemainingTimeIndicator extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = context.l10n; + return Column( children: [ DecoratedBox( @@ -171,11 +166,7 @@ class _RemainingTimeIndicator extends StatelessWidget { ), Text( l10n.remaining, - style: const TextStyle( - color: Colors.grey, - fontSize: 14, - fontWeight: FontWeight.w600, - ), + style: AesTextStyles.labelLarge, ), ], ); diff --git a/lib/overview/widgets/movie_card.dart b/lib/overview/widgets/movie_card.dart index cac8f2c..d0ac68e 100644 --- a/lib/overview/widgets/movie_card.dart +++ b/lib/overview/widgets/movie_card.dart @@ -1,3 +1,4 @@ +import 'package:aes_ui/aes_ui.dart'; import 'package:airplane_entertainment_system/generated/assets.gen.dart'; import 'package:airplane_entertainment_system/l10n/l10n.dart'; import 'package:flutter/material.dart'; @@ -8,6 +9,7 @@ class MovieCard extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = context.l10n; + return SizedBox( height: 200, child: Card( @@ -23,10 +25,7 @@ class MovieCard extends StatelessWidget { children: [ const Text( 'Spiderman...', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), + style: AesTextStyles.titleLarge, ), Text( '120 ${l10n.minutesShort}', diff --git a/lib/overview/widgets/music_card.dart b/lib/overview/widgets/music_card.dart index 720983f..adac394 100644 --- a/lib/overview/widgets/music_card.dart +++ b/lib/overview/widgets/music_card.dart @@ -1,3 +1,4 @@ +import 'package:aes_ui/aes_ui.dart'; import 'package:airplane_entertainment_system/generated/assets.gen.dart'; import 'package:airplane_entertainment_system/l10n/l10n.dart'; import 'package:flutter/material.dart'; @@ -8,6 +9,7 @@ class MusicCard extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = context.l10n; + return SizedBox( height: 200, child: Card( @@ -23,10 +25,7 @@ class MusicCard extends StatelessWidget { children: [ Text( l10n.goodVibes, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), + style: AesTextStyles.titleLarge, ), Text( l10n.multipleArtists, diff --git a/packages/aes_ui/.gitignore b/packages/aes_ui/.gitignore new file mode 100644 index 0000000..06ef8e6 --- /dev/null +++ b/packages/aes_ui/.gitignore @@ -0,0 +1,44 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# VSCode related +.vscode/* + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ +pubspec.lock + +# Web related +lib/generated_plugin_registrant.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Test related +coverage \ No newline at end of file diff --git a/packages/aes_ui/README.md b/packages/aes_ui/README.md new file mode 100644 index 0000000..2f20ceb --- /dev/null +++ b/packages/aes_ui/README.md @@ -0,0 +1,3 @@ +# Aes Ui + +UI package for the Airplane Entertainment System. diff --git a/packages/aes_ui/analysis_options.yaml b/packages/aes_ui/analysis_options.yaml new file mode 100644 index 0000000..bb72091 --- /dev/null +++ b/packages/aes_ui/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.6.0.0.yaml diff --git a/packages/aes_ui/lib/aes_ui.dart b/packages/aes_ui/lib/aes_ui.dart new file mode 100644 index 0000000..ac8788e --- /dev/null +++ b/packages/aes_ui/lib/aes_ui.dart @@ -0,0 +1,5 @@ +/// UI package for the Airplane Entertainment System. +library; + +export 'src/theme/theme.dart'; +export 'src/widgets/widgets.dart'; diff --git a/packages/aes_ui/lib/src/theme/aes_text_styles.dart b/packages/aes_ui/lib/src/theme/aes_text_styles.dart new file mode 100644 index 0000000..d7d8696 --- /dev/null +++ b/packages/aes_ui/lib/src/theme/aes_text_styles.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +/// {@template aes_text_styles} +/// A collection of [TextStyle] objects used in the +/// Airplane Entertainment System. +/// {@endtemplate} +class AesTextStyles { + /// Large headline text style. + static const TextStyle headlineLarge = TextStyle( + fontSize: 80, + fontWeight: FontWeight.w600, + height: 1, + ); + + /// Large title text style. + static const TextStyle titleLarge = TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ); + + /// Large label text style. + static const TextStyle labelLarge = TextStyle( + color: Colors.grey, + fontSize: 14, + fontWeight: FontWeight.w600, + ); +} diff --git a/packages/aes_ui/lib/src/theme/aes_theme.dart b/packages/aes_ui/lib/src/theme/aes_theme.dart new file mode 100644 index 0000000..08ad053 --- /dev/null +++ b/packages/aes_ui/lib/src/theme/aes_theme.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +/// {@template aes_theme} +/// Airplane Entertainment System theme. +/// {@endtemplate} +class AesTheme { + /// {@macro aes_theme} + const AesTheme(); + + /// [ThemeData] for the Airplane Entertainment System. + ThemeData get themeData { + return ThemeData( + useMaterial3: true, + navigationRailTheme: const NavigationRailThemeData( + labelType: NavigationRailLabelType.none, + selectedIconTheme: IconThemeData(size: 36, color: Colors.red), + unselectedIconTheme: IconThemeData(size: 36, color: Colors.black), + groupAlignment: 0, + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: Colors.white, + elevation: 0, + iconTheme: WidgetStateProperty.resolveWith( + (Set states) => states.contains(WidgetState.selected) + ? const IconThemeData(color: Colors.red) + : const IconThemeData(color: Colors.black), + ), + ), + ); + } +} diff --git a/packages/aes_ui/lib/src/theme/theme.dart b/packages/aes_ui/lib/src/theme/theme.dart new file mode 100644 index 0000000..abcf285 --- /dev/null +++ b/packages/aes_ui/lib/src/theme/theme.dart @@ -0,0 +1,2 @@ +export 'aes_text_styles.dart'; +export 'aes_theme.dart'; diff --git a/packages/aes_ui/lib/src/widgets/aes_bottom_navigation_bar.dart b/packages/aes_ui/lib/src/widgets/aes_bottom_navigation_bar.dart new file mode 100644 index 0000000..154e737 --- /dev/null +++ b/packages/aes_ui/lib/src/widgets/aes_bottom_navigation_bar.dart @@ -0,0 +1,38 @@ +import 'package:aes_ui/aes_ui.dart'; +import 'package:flutter/material.dart'; + +/// {@template aes_bottom_navigation_bar} +/// A custom bottom navigation bar for the Airplane Entertainment System. +/// {@endtemplate} +class AesBottomNavigationBar extends StatelessWidget { + /// {@macro aes_bottom_navigation_bar} + const AesBottomNavigationBar({ + required this.destinations, + required this.selectedIndex, + this.onDestinationSelected, + super.key, + }); + + /// The destinations to display in the bottom navigation bar. + final List destinations; + + /// The index of the selected destination. + final int selectedIndex; + + /// A callback that is called when a destination is selected. + final ValueChanged? onDestinationSelected; + + @override + Widget build(BuildContext context) { + return NavigationBar( + selectedIndex: selectedIndex, + onDestinationSelected: onDestinationSelected, + destinations: destinations.map((destination) { + return NavigationDestination( + icon: destination.widget, + label: destination.label, + ); + }).toList(), + ); + } +} diff --git a/packages/aes_ui/lib/src/widgets/aes_layout.dart b/packages/aes_ui/lib/src/widgets/aes_layout.dart new file mode 100644 index 0000000..2a19692 --- /dev/null +++ b/packages/aes_ui/lib/src/widgets/aes_layout.dart @@ -0,0 +1,89 @@ +import 'package:flutter/widgets.dart'; + +/// {@template aes_layout} +/// Describes the layout of the current window. +/// {@endtemplate} +enum AesLayoutData { + /// A small layout. + /// + /// Typically used for mobile devices. + small, + + /// A large layout. + /// + /// Typically used for tablets and desktops. + large; + + /// Derives the layout from the given [windowSize]. + static AesLayoutData _derive(Size windowSize) { + return windowSize.width < windowSize.height || + windowSize.width < AesLayout.mobileBreakpoint + ? AesLayoutData.small + : AesLayoutData.large; + } +} + +/// {@template aes_layout} +/// Derives and provides [AesLayoutData] to its descendants. +/// +/// See also: +/// +/// * [AesLayout.of], a static method which retrieves the +/// closest [AesLayoutData]. +/// * [AesLayoutData], an enum which describes the layout of the current window. +/// {@endtemplate} +class AesLayout extends StatelessWidget { + /// {@macro aes_layout} + const AesLayout({ + required this.child, + this.data, + super.key, + }); + + /// The threshold width at which the layout should change. + static const double mobileBreakpoint = 600; + + /// The layout to provide to the child. + /// + /// If `null` it is derived from the current size of the window. Otherwise, + /// it will be fixed to the provided value. + final AesLayoutData? data; + + /// The child widget which will have access to the current [AesLayoutData]. + final Widget child; + + /// Retrieves the closest [_AesLayoutScope] from the given [context]. + static AesLayoutData of(BuildContext context) { + final layout = + context.dependOnInheritedWidgetOfExactType<_AesLayoutScope>(); + assert(layout != null, 'No AesLayout found in context'); + return layout!.layout; + } + + @override + Widget build(BuildContext context) { + return _AesLayoutScope( + layout: data ?? AesLayoutData._derive(MediaQuery.sizeOf(context)), + child: child, + ); + } +} + +/// {@template aes_layout_scope} +/// A widget which provides the current [AesLayoutData] to its descendants. +/// {@endtemplate} +class _AesLayoutScope extends InheritedWidget { + /// {@macro aes_layout_scope} + const _AesLayoutScope({ + required super.child, + required this.layout, + }); + + /// {@macro aes_layout_data} + final AesLayoutData layout; + + @override + bool updateShouldNotify(covariant _AesLayoutScope oldWidget) { + return layout != oldWidget.layout; + } +} diff --git a/packages/aes_ui/lib/src/widgets/aes_navigation_rail.dart b/packages/aes_ui/lib/src/widgets/aes_navigation_rail.dart new file mode 100644 index 0000000..071081d --- /dev/null +++ b/packages/aes_ui/lib/src/widgets/aes_navigation_rail.dart @@ -0,0 +1,72 @@ +import 'package:aes_ui/aes_ui.dart'; +import 'package:flutter/material.dart'; + +/// {@template aes_navigation_rail} +/// A navigation rail for the Airplane Entertainment System. +/// {@endtemplate} +class AesNavigationRail extends StatelessWidget { + /// {@macro aes_navigation_rail} + const AesNavigationRail({ + required this.destinations, + required this.selectedIndex, + this.onDestinationSelected, + super.key, + }); + + /// The destinations to display in the navigation rail. + final List destinations; + + /// The index of the selected destination. + final int selectedIndex; + + /// A callback that is called when a destination is selected. + final ValueChanged? onDestinationSelected; + + @override + Widget build(BuildContext context) { + return ClipPath( + clipper: const _CustomRectangularShape(), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 80, maxHeight: 550), + child: Center( + child: NavigationRail( + selectedIndex: selectedIndex, + onDestinationSelected: onDestinationSelected, + destinations: destinations.map((destination) { + return NavigationRailDestination( + icon: destination.widget, + label: Text(destination.label), + ); + }).toList(), + ), + ), + ), + ); + } +} + +class _CustomRectangularShape extends CustomClipper { + const _CustomRectangularShape(); + + @override + Path getClip(Size size) { + final path = Path() + ..lineTo(size.width - 10, 50) + ..quadraticBezierTo(size.width, 55, size.width, 70) + ..lineTo(size.width, size.height - 70) + ..quadraticBezierTo( + size.width, + size.height - 55, + size.width - 10, + size.height - 50, + ) + ..lineTo(0, size.height); + + return path; + } + + // coverage:ignore-start + @override + bool shouldReclip(CustomClipper oldClipper) => false; + // coverage:ignore-end +} diff --git a/packages/aes_ui/lib/src/widgets/models/destination.dart b/packages/aes_ui/lib/src/widgets/models/destination.dart new file mode 100644 index 0000000..81f3787 --- /dev/null +++ b/packages/aes_ui/lib/src/widgets/models/destination.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +/// {@template destination} +/// A model representing a destination in the app. +/// {@endtemplate} +class Destination { + /// {@macro destination} + const Destination( + this.widget, + this.label, + ); + + /// The widget to display for this destination. + final Widget widget; + + /// The label to display for this destination. + final String label; +} diff --git a/packages/aes_ui/lib/src/widgets/models/models.dart b/packages/aes_ui/lib/src/widgets/models/models.dart new file mode 100644 index 0000000..f47ea15 --- /dev/null +++ b/packages/aes_ui/lib/src/widgets/models/models.dart @@ -0,0 +1 @@ +export 'destination.dart'; diff --git a/packages/aes_ui/lib/src/widgets/widgets.dart b/packages/aes_ui/lib/src/widgets/widgets.dart new file mode 100644 index 0000000..b17e9e6 --- /dev/null +++ b/packages/aes_ui/lib/src/widgets/widgets.dart @@ -0,0 +1,4 @@ +export 'aes_bottom_navigation_bar.dart'; +export 'aes_layout.dart'; +export 'aes_navigation_rail.dart'; +export 'models/models.dart'; diff --git a/packages/aes_ui/pubspec.yaml b/packages/aes_ui/pubspec.yaml new file mode 100644 index 0000000..0c8a992 --- /dev/null +++ b/packages/aes_ui/pubspec.yaml @@ -0,0 +1,19 @@ +name: aes_ui +description: UI package for the Airplane Entertainment System. +version: 0.1.0+1 +publish_to: none + +environment: + sdk: ^3.4.0 + flutter: ^3.22.0 + +dependencies: + equatable: ^2.0.5 + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + mocktail: ^1.0.4 + very_good_analysis: ^6.0.0 diff --git a/packages/aes_ui/test/src/theme/aes_theme_test.dart b/packages/aes_ui/test/src/theme/aes_theme_test.dart new file mode 100644 index 0000000..73b3136 --- /dev/null +++ b/packages/aes_ui/test/src/theme/aes_theme_test.dart @@ -0,0 +1,39 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:aes_ui/aes_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('$AesTheme', () { + test('uses material 3', () { + expect(AesTheme().themeData.useMaterial3, isTrue); + }); + + test('uses navigationRailTheme', () { + expect(AesTheme().themeData.navigationRailTheme, isNotNull); + }); + + group('NavigationBarTheme', () { + test('uses navigationBarTheme', () { + expect(AesTheme().themeData.navigationBarTheme, isNotNull); + }); + + test('uses red icon when selected', () { + final iconTheme = AesTheme().themeData.navigationBarTheme.iconTheme; + expect( + iconTheme!.resolve({WidgetState.selected}), + const IconThemeData(color: Colors.red), + ); + }); + + test('uses black icon when not selected', () { + final iconTheme = AesTheme().themeData.navigationBarTheme.iconTheme; + expect( + iconTheme!.resolve({WidgetState.disabled}), + const IconThemeData(color: Colors.black), + ); + }); + }); + }); +} diff --git a/packages/aes_ui/test/src/widgets/aes_bottom_navigation_bar_test.dart b/packages/aes_ui/test/src/widgets/aes_bottom_navigation_bar_test.dart new file mode 100644 index 0000000..38057eb --- /dev/null +++ b/packages/aes_ui/test/src/widgets/aes_bottom_navigation_bar_test.dart @@ -0,0 +1,34 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:aes_ui/aes_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('$AesBottomNavigationBar', () { + const destinations = [ + Destination( + Icon(Icons.airplanemode_active), + 'Airplane', + ), + Destination( + Icon(Icons.headset), + 'Music', + ), + ]; + + testWidgets('finds one $AesBottomNavigationBar widget', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AesBottomNavigationBar( + destinations: destinations, + selectedIndex: 0, + ), + ), + ), + ); + expect(find.byType(AesBottomNavigationBar), findsOneWidget); + }); + }); +} diff --git a/packages/aes_ui/test/src/widgets/aes_layout_test.dart b/packages/aes_ui/test/src/widgets/aes_layout_test.dart new file mode 100644 index 0000000..9244493 --- /dev/null +++ b/packages/aes_ui/test/src/widgets/aes_layout_test.dart @@ -0,0 +1,177 @@ +import 'package:aes_ui/aes_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('$AesLayout', () { + testWidgets('renders successfully', (tester) async { + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(), + child: AesLayout(child: SizedBox()), + ), + ); + + expect(find.byType(AesLayout), findsOneWidget); + }); + + group('of', () { + testWidgets( + 'throws an $AssertionError if no $AesLayout is found in context', + (tester) async { + late final BuildContext buildContext; + + await tester.pumpWidget( + Builder( + builder: (context) { + buildContext = context; + return const SizedBox(); + }, + ), + ); + + expect( + () => AesLayout.of(buildContext), + throwsAssertionError, + ); + }, + ); + + testWidgets('returns the closest $AesLayoutData', (tester) async { + const data = AesLayoutData.small; + + late final BuildContext buildContext; + await tester.pumpWidget( + AesLayout( + data: data, + child: Builder( + builder: (context) { + buildContext = context; + return const SizedBox(); + }, + ), + ), + ); + + expect(AesLayout.of(buildContext), equals(data)); + }); + + group('is small', () { + testWidgets( + 'when the width is smaller than the mobile breakpoint', + (tester) async { + late final BuildContext buildContext; + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData( + size: Size(AesLayout.mobileBreakpoint - 1, 200), + ), + child: AesLayout( + child: Builder( + builder: (context) { + buildContext = context; + return const SizedBox(); + }, + ), + ), + ), + ); + + expect(AesLayout.of(buildContext), equals(AesLayoutData.small)); + }, + ); + }); + + group('is large', () { + testWidgets( + 'when the width is at the mobile breakpoint', + (tester) async { + late final BuildContext buildContext; + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData( + size: Size(AesLayout.mobileBreakpoint, 200), + ), + child: AesLayout( + child: Builder( + builder: (context) { + buildContext = context; + return const SizedBox(); + }, + ), + ), + ), + ); + + expect(AesLayout.of(buildContext), equals(AesLayoutData.large)); + }, + ); + + testWidgets( + 'when the width is greater than the mobile breakpoint', + (tester) async { + late final BuildContext buildContext; + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData( + size: Size(AesLayout.mobileBreakpoint + 1, 200), + ), + child: AesLayout( + child: Builder( + builder: (context) { + buildContext = context; + return const SizedBox(); + }, + ), + ), + ), + ); + + expect(AesLayout.of(buildContext), equals(AesLayoutData.large)); + }, + ); + }); + + testWidgets( + 'changes when the $MediaQueryData changes', + (tester) async { + BuildContext? buildContext; + StateSetter? stateSetter; + + var data = const MediaQueryData(size: Size(100, 200)); + + await tester.pumpWidget( + StatefulBuilder( + builder: (context, setState) { + stateSetter ??= setState; + return MediaQuery( + data: data, + child: AesLayout( + child: Builder( + builder: (context) { + buildContext ??= context; + return const SizedBox(); + }, + ), + ), + ); + }, + ), + ); + + expect(AesLayout.of(buildContext!), equals(AesLayoutData.small)); + + stateSetter!(() { + data = const MediaQueryData( + size: Size(AesLayout.mobileBreakpoint, 100), + ); + }); + await tester.pumpAndSettle(); + + expect(AesLayout.of(buildContext!), equals(AesLayoutData.large)); + }, + ); + }); + }); +} diff --git a/packages/aes_ui/test/src/widgets/aes_navigation_rail_test.dart b/packages/aes_ui/test/src/widgets/aes_navigation_rail_test.dart new file mode 100644 index 0000000..c8e6195 --- /dev/null +++ b/packages/aes_ui/test/src/widgets/aes_navigation_rail_test.dart @@ -0,0 +1,34 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:aes_ui/aes_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('$AesNavigationRail', () { + const destinations = [ + Destination( + Icon(Icons.airplanemode_active), + 'Airplane', + ), + Destination( + Icon(Icons.headset), + 'Music', + ), + ]; + + testWidgets('finds one $AesNavigationRail widget', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AesNavigationRail( + destinations: destinations, + selectedIndex: 0, + ), + ), + ), + ); + expect(find.byType(AesNavigationRail), findsOneWidget); + }); + }); +} diff --git a/pubspec.lock b/pubspec.lock index 33e05c3..db31e8e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,6 +9,13 @@ packages: url: "https://pub.dev" source: hosted version: "67.0.0" + aes_ui: + dependency: "direct main" + description: + path: "packages/aes_ui" + relative: true + source: path + version: "0.1.0+1" analyzer: dependency: transitive description: @@ -145,6 +152,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.1" + equatable: + dependency: transitive + description: + name: equatable + sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 + url: "https://pub.dev" + source: hosted + version: "2.0.5" fake_async: dependency: transitive description: @@ -647,4 +662,4 @@ packages: version: "3.1.2" sdks: dart: ">=3.4.0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.22.0" diff --git a/pubspec.yaml b/pubspec.yaml index fa5986b..25115fc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,6 +7,8 @@ environment: sdk: ^3.4.0 dependencies: + aes_ui: + path: packages/aes_ui bloc: ^8.1.4 flutter: sdk: flutter diff --git a/test/airplane_entertainment_system/view/airplane_entertainment_system_screen_test.dart b/test/airplane_entertainment_system/view/airplane_entertainment_system_screen_test.dart index 6e0375e..21d400d 100644 --- a/test/airplane_entertainment_system/view/airplane_entertainment_system_screen_test.dart +++ b/test/airplane_entertainment_system/view/airplane_entertainment_system_screen_test.dart @@ -1,3 +1,4 @@ +import 'package:aes_ui/aes_ui.dart'; import 'package:airplane_entertainment_system/airplane_entertainment_system/airplane_entertainment_system.dart'; import 'package:airplane_entertainment_system/music_player/music_player.dart'; import 'package:airplane_entertainment_system/overview/overview.dart'; @@ -7,50 +8,84 @@ import 'package:flutter_test/flutter_test.dart'; import '../../helpers/helpers.dart'; void main() { - group('AirplaneEntertainmentSystemScreen', () { - testWidgets('shows LeftSideNavigationRail', (tester) async { - await tester.pumpExperience(const AirplaneEntertainmentSystemScreen()); + group('$AirplaneEntertainmentSystemScreen', () { + testWidgets('shows $AesNavigationRail on large screens', (tester) async { + await tester.binding.setSurfaceSize(const Size(1600, 1200)); + await tester.pumpApp( + const AirplaneEntertainmentSystemScreen(), + layout: AesLayoutData.large, + ); - expect(find.byType(LeftSideNavigationRail), findsOneWidget); + expect(find.byType(AesNavigationRail), findsOneWidget); }); + + testWidgets('shows $AesBottomNavigationBar on small screens', + (tester) async { + await tester.pumpApp( + const AirplaneEntertainmentSystemScreen(), + layout: AesLayoutData.small, + ); + + expect(find.byType(AesBottomNavigationBar), findsOneWidget); + }); + testWidgets('shows TopButtonBar', (tester) async { - await tester.pumpExperience(const AirplaneEntertainmentSystemScreen()); + await tester.pumpApp( + const AirplaneEntertainmentSystemScreen(), + layout: AesLayoutData.small, + ); expect(find.byType(TopButtonBar), findsOneWidget); }); testWidgets('contains background', (tester) async { - await tester.pumpExperience(const AirplaneEntertainmentSystemScreen()); + await tester.pumpApp( + const AirplaneEntertainmentSystemScreen(), + layout: AesLayoutData.small, + ); expect(find.byType(SystemBackground), findsOneWidget); }); testWidgets('shows OverviewPage initially', (tester) async { - await tester.pumpExperience(const AirplaneEntertainmentSystemScreen()); + await tester.pumpApp( + const AirplaneEntertainmentSystemScreen(), + layout: AesLayoutData.small, + ); expect(find.byType(OverviewPage), findsOneWidget); }); testWidgets('shows MusicPlayerPage when icon is selected', (tester) async { - await tester.pumpExperience(const AirplaneEntertainmentSystemScreen()); + await tester.pumpApp( + const AirplaneEntertainmentSystemScreen(), + layout: AesLayoutData.small, + ); - await tester.tap(find.byIcon(Icons.headphones)); + await tester.tap(find.byIcon(Icons.music_note)); await tester.pump(); await tester.pump(const Duration(milliseconds: 650)); expect(find.byType(MusicPlayerPage), findsOneWidget); }); - testWidgets('shows OverviewPage when icon is selected', (tester) async { - await tester.pumpExperience(const AirplaneEntertainmentSystemScreen()); + for (final layout in AesLayoutData.values) { + testWidgets( + 'shows $OverviewPage when icon is ' + 'selected for $layout layout', (tester) async { + await tester.pumpApp( + const AirplaneEntertainmentSystemScreen(), + layout: layout, + ); - await tester.tap(find.byIcon(Icons.headphones)); - await tester.pump(const Duration(milliseconds: 600)); + await tester.tap(find.byIcon(Icons.music_note)); + await tester.pump(const Duration(milliseconds: 600)); - await tester.tap(find.byIcon(Icons.airplanemode_active_outlined)); - await tester.pump(const Duration(milliseconds: 600)); + await tester.tap(find.byIcon(Icons.airplanemode_active_outlined)); + await tester.pump(const Duration(milliseconds: 600)); - expect(find.byType(OverviewPage), findsOneWidget); - }); + expect(find.byType(OverviewPage), findsOneWidget); + }); + } }); } diff --git a/test/airplane_entertainment_system/widgets/left_side_navigation_rail_test.dart b/test/airplane_entertainment_system/widgets/left_side_navigation_rail_test.dart deleted file mode 100644 index ccb5701..0000000 --- a/test/airplane_entertainment_system/widgets/left_side_navigation_rail_test.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:airplane_entertainment_system/airplane_entertainment_system/airplane_entertainment_system.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('CustomRectangularShape', () { - test('verifies should not reclip', () async { - final path = CustomRectangularShape(); - expect(path.shouldReclip(path), false); - }); - }); -} diff --git a/test/airplane_entertainment_system/widgets/top_button_bar_test.dart b/test/airplane_entertainment_system/widgets/top_button_bar_test.dart index 5f535c2..c6207a2 100644 --- a/test/airplane_entertainment_system/widgets/top_button_bar_test.dart +++ b/test/airplane_entertainment_system/widgets/top_button_bar_test.dart @@ -7,7 +7,7 @@ import '../../helpers/helpers.dart'; void main() { group('TopButtonBar', () { testWidgets('contains power button', (tester) async { - await tester.pumpExperience( + await tester.pumpApp( const Scaffold( body: TopButtonBar(), ), @@ -18,7 +18,7 @@ void main() { }); testWidgets('contains brightness button', (tester) async { - await tester.pumpExperience( + await tester.pumpApp( const Scaffold( body: TopButtonBar(), ), @@ -29,7 +29,7 @@ void main() { }); testWidgets('contains volume button', (tester) async { - await tester.pumpExperience( + await tester.pumpApp( const Scaffold( body: TopButtonBar(), ), @@ -40,7 +40,7 @@ void main() { }); testWidgets('contains assist button', (tester) async { - await tester.pumpExperience( + await tester.pumpApp( const Scaffold( body: TopButtonBar(), ), diff --git a/test/helpers/pump_experience.dart b/test/helpers/pump_experience.dart index d240c92..fac6eaa 100644 --- a/test/helpers/pump_experience.dart +++ b/test/helpers/pump_experience.dart @@ -1,14 +1,18 @@ +import 'package:aes_ui/aes_ui.dart'; import 'package:airplane_entertainment_system/l10n/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -extension PumpExperience on WidgetTester { - Future pumpExperience(Widget widget) { +extension PumpApp on WidgetTester { + Future pumpApp(Widget widget, {AesLayoutData? layout}) { return pumpWidget( - MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: widget, + AesLayout( + data: layout, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: widget, + ), ), ); } diff --git a/test/music_player/view/music_player_page_test.dart b/test/music_player/view/music_player_page_test.dart index 78f2f79..aaf6a3c 100644 --- a/test/music_player/view/music_player_page_test.dart +++ b/test/music_player/view/music_player_page_test.dart @@ -7,7 +7,7 @@ import '../../helpers/pump_experience.dart'; void main() { group('MusicPlayerPage', () { testWidgets('contains MusicPlayerPage', (tester) async { - await tester.pumpExperience( + await tester.pumpApp( const Scaffold( body: MusicPlayerPage(), ), @@ -18,7 +18,7 @@ void main() { }); testWidgets('contains back button', (tester) async { - await tester.pumpExperience( + await tester.pumpApp( const Scaffold( body: MusicPlayerPage(), ), @@ -30,7 +30,7 @@ void main() { }); testWidgets('contains slider and changing it does nothing', (tester) async { - await tester.pumpExperience( + await tester.pumpApp( const Scaffold( body: MusicPlayerPage(), ), diff --git a/test/overview/view/overview_page_test.dart b/test/overview/view/overview_page_test.dart index b2f4e69..b26a743 100644 --- a/test/overview/view/overview_page_test.dart +++ b/test/overview/view/overview_page_test.dart @@ -5,9 +5,9 @@ import 'package:flutter_test/flutter_test.dart'; import '../../helpers/helpers.dart'; void main() { - group('OverviewPage', () { + group('$OverviewPage', () { testWidgets('contains welcome copy', (tester) async { - await tester.pumpExperience(const OverviewPage()); + await tester.pumpApp(const OverviewPage()); expect(find.byType(WelcomeCopy), findsOneWidget); }); @@ -15,7 +15,7 @@ void main() { testWidgets('contains flight tracker', (tester) async { await tester.binding.setSurfaceSize(const Size(1600, 1200)); addTearDown(() => tester.binding.setSurfaceSize(null)); - await tester.pumpExperience(const OverviewPage()); + await tester.pumpApp(const OverviewPage()); await tester.dragUntilVisible( find.byType(FlightTrackingCard), diff --git a/test/thumbnail/airplane_entertainment_system_thumbnail_test.dart b/test/thumbnail/airplane_entertainment_system_thumbnail_test.dart index 1606de4..999a404 100644 --- a/test/thumbnail/airplane_entertainment_system_thumbnail_test.dart +++ b/test/thumbnail/airplane_entertainment_system_thumbnail_test.dart @@ -8,7 +8,7 @@ import '../helpers/helpers.dart'; void main() { group('AirplaneEntertainmentSystemThumbnail', () { testWidgets('renders an airplane image and clouds', (tester) async { - await tester.pumpExperience(const AirplaneEntertainmentSystemThumbnail()); + await tester.pumpApp(const AirplaneEntertainmentSystemThumbnail()); expect(find.byType(Clouds), findsNWidgets(2)); expect(find.byType(AirplaneImage), findsOneWidget); });