From 2eb44517cb5144e849734b87b1391b7fa51dd802 Mon Sep 17 00:00:00 2001 From: Joaquin Neschisi <39923559+jneschisi@users.noreply.github.com> Date: Mon, 29 Jul 2024 09:47:08 -0300 Subject: [PATCH] feat: adaptive layout (#4) * feat: adaptive layout * fixed tests * fix: music list scroll --- .../airplane_entertainment_system_screen.dart | 104 ++++---- .../widgets/top_button_bar.dart | 9 +- lib/music_player/view/music_player_page.dart | 235 ++++++++++++++---- lib/overview/view/overview_page.dart | 95 +++---- .../aes_ui/lib/src/widgets/aes_layout.dart | 26 +- .../test/src/widgets/aes_layout_test.dart | 36 ++- test/helpers/pump_experience.dart | 7 +- .../view/music_player_page_test.dart | 16 +- 8 files changed, 361 insertions(+), 167 deletions(-) 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 1cc9f6e..dcc5112 100644 --- a/lib/airplane_entertainment_system/view/airplane_entertainment_system_screen.dart +++ b/lib/airplane_entertainment_system/view/airplane_entertainment_system_screen.dart @@ -41,53 +41,56 @@ class _AirplaneEntertainmentSystemScreenState page: _currentPage, ), ), - Column( - children: [ - const TopButtonBar(), - Expanded( - child: Row( - children: [ - if (layout == AesLayoutData.large) - AesNavigationRail( - destinations: destinations, - selectedIndex: _currentPage, - onDestinationSelected: (value) { - setState(() { - _currentPage = value; - }); - }, - ), - Expanded( - child: _ContentPageView( - pageSize: Size( - constraints.maxWidth, - constraints.maxHeight, + SafeArea( + child: Column( + children: [ + const TopButtonBar(), + Expanded( + child: Row( + children: [ + if (layout != AesLayoutData.small) + AesNavigationRail( + destinations: destinations, + selectedIndex: _currentPage, + onDestinationSelected: (value) { + setState(() { + _currentPage = value; + }); + }, + ), + Expanded( + child: _ContentPageView( + pageSize: Size( + constraints.maxWidth, + constraints.maxHeight, + ), + pageIndex: _currentPage, ), - pageIndex: _currentPage, ), - ), - ], + ], + ), ), - ), - ], + ], + ), ), // Display clouds over the airplane only on the first screen. - Positioned.fill( - left: 80, - right: constraints.maxWidth * 0.4, - child: IgnorePointer( - child: AnimatedOpacity( - duration: const Duration(milliseconds: 600), - opacity: _currentPage == 0 ? 0.8 : 0, - child: const Clouds( - key: Key('foregroundClouds'), - count: 3, - averageScale: 0.25, - averageVelocity: 2, + if (layout == AesLayoutData.large) + Positioned.fill( + left: 80, + right: constraints.maxWidth * 0.4, + child: IgnorePointer( + child: AnimatedOpacity( + duration: const Duration(milliseconds: 600), + opacity: _currentPage == 0 ? 0.8 : 0, + child: const Clouds( + key: Key('foregroundClouds'), + count: 3, + averageScale: 0.25, + averageVelocity: 2, + ), ), ), ), - ), ], ); }, @@ -173,12 +176,17 @@ class _ContentPageViewState extends State<_ContentPageView> @override Widget build(BuildContext context) { - final pageOffset = widget.pageSize.height / 4; + final isSmall = AesLayout.of(context) == AesLayoutData.small; + final pageSize = widget.pageSize; + final pageSide = isSmall ? pageSize.width : pageSize.width.hashCode; + final pageOffset = pageSide / 4; + final axis = isSmall ? Axis.horizontal : Axis.vertical; return Stack( children: [ if (_previousPage != _currentPage) _PositionedFadeTransition( + axis: axis, positionAnimation: _controller.drive( CurveTween( curve: Curves.easeInOut, @@ -200,6 +208,7 @@ class _ContentPageViewState extends State<_ContentPageView> child: _pages[_previousPage], ), _PositionedFadeTransition( + axis: axis, positionAnimation: _controller.drive( CurveTween( curve: Curves.easeInOut, @@ -229,6 +238,7 @@ class _PositionedFadeTransition extends StatelessWidget { required this.opacityAnimation, required this.pageSize, required this.child, + required this.axis, this.beginOffset = 0, this.endOffset = 0, }); @@ -239,15 +249,21 @@ class _PositionedFadeTransition extends StatelessWidget { final Widget child; final double beginOffset; final double endOffset; + final Axis axis; @override Widget build(BuildContext context) { + final begin = axis == Axis.horizontal + ? RelativeRect.fromLTRB(beginOffset, 0, -beginOffset, 0) + : RelativeRect.fromLTRB(0, beginOffset, 0, -beginOffset); + + final end = axis == Axis.horizontal + ? RelativeRect.fromLTRB(endOffset, 0, -endOffset, 0) + : RelativeRect.fromLTRB(0, endOffset, 0, -endOffset); + return PositionedTransition( rect: positionAnimation.drive( - RelativeRectTween( - begin: RelativeRect.fromLTRB(0, beginOffset, 0, -beginOffset), - end: RelativeRect.fromLTRB(0, endOffset, 0, -endOffset), - ), + RelativeRectTween(begin: begin, end: end), ), child: FadeTransition( key: ValueKey(child.key), diff --git a/lib/airplane_entertainment_system/widgets/top_button_bar.dart b/lib/airplane_entertainment_system/widgets/top_button_bar.dart index 73f5d83..f8ea648 100644 --- a/lib/airplane_entertainment_system/widgets/top_button_bar.dart +++ b/lib/airplane_entertainment_system/widgets/top_button_bar.dart @@ -53,8 +53,13 @@ class TopButtonBar extends StatelessWidget { letterSpacing: 1.2, ), ), - icon: const Icon(Icons.support), - label: Text(context.l10n.assistButton), + icon: const Icon(Icons.support, color: Colors.white), + label: Text( + context.l10n.assistButton, + style: const TextStyle( + color: Colors.white, + ), + ), ), ], ), diff --git a/lib/music_player/view/music_player_page.dart b/lib/music_player/view/music_player_page.dart index 8f7482d..0bc6f0e 100644 --- a/lib/music_player/view/music_player_page.dart +++ b/lib/music_player/view/music_player_page.dart @@ -8,11 +8,50 @@ class MusicPlayerPage extends StatelessWidget { @override Widget build(BuildContext context) { - return Row( + final layout = AesLayout.of(context); + + return switch (layout) { + AesLayoutData.small => const _SmallMusicPlayerPage(), + AesLayoutData.medium || + AesLayoutData.large => + const _LargeMusicPlayerPage(), + }; + } +} + +class _SmallMusicPlayerPage extends StatelessWidget { + const _SmallMusicPlayerPage(); + + @override + Widget build(BuildContext context) { + return const Stack( + children: [ + MusicMenuView( + padding: EdgeInsets.only( + right: 20, + left: 20, + bottom: 60, + ), + ), + Positioned( + bottom: 20, + right: 30, + child: MusicFloatingButton(), + ), + ], + ); + } +} + +class _LargeMusicPlayerPage extends StatelessWidget { + const _LargeMusicPlayerPage(); + + @override + Widget build(BuildContext context) { + return const Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - const Expanded( - flex: 3, + Expanded( child: FittedBox( fit: BoxFit.scaleDown, child: Padding( @@ -22,11 +61,118 @@ class MusicPlayerPage extends StatelessWidget { ), ), Expanded( - flex: 2, - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 20).copyWith(top: 100), - child: const MusicMenuView(), + child: MusicMenuView( + padding: EdgeInsets.only( + top: 40, + right: 80, + left: 40, + bottom: 100, + ), + ), + ), + ], + ); + } +} + +class MusicFloatingButton extends StatelessWidget { + const MusicFloatingButton({super.key}); + + @override + Widget build(BuildContext context) { + return Stack( + alignment: Alignment.center, + children: [ + Transform.scale( + scale: 0.2, + child: const MusicVisualizer(), + ), + SizedBox( + height: 50, + width: 50, + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.5), + spreadRadius: 5, + blurRadius: 7, + offset: const Offset(0, 3), + ), + ], + ), + child: InkWell( + onTap: () { + showBottomSheet( + backgroundColor: Colors.white, + context: context, + constraints: const BoxConstraints( + maxHeight: 380, + ), + builder: (_) => const _MusicBottomSheet(), + ); + }, + child: Center( + child: Icon( + Icons.play_circle, + size: 32, + color: Colors.black.withOpacity(0.5), + ), + ), + ), + ), + ), + ], + ); + } +} + +class _MusicBottomSheet extends StatelessWidget { + const _MusicBottomSheet(); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Padding( + padding: const EdgeInsets.all(40).copyWith(bottom: 0), + child: Column( + children: [ + const SizedBox( + width: 350, + child: FittedBox( + fit: BoxFit.scaleDown, + child: MusicPlayerView(), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _musicItems[0]['title']!, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + const Text(' - '), + Text(_musicItems[0]['artist']!), + ], + ), + ], + ), + ), + Positioned( + top: 10, + right: 10, + child: IconButton( + onPressed: Navigator.of(context).pop, + icon: const Icon( + Icons.close_rounded, + size: 30, + ), ), ), ], @@ -47,24 +193,6 @@ class MusicPlayerView extends StatelessWidget { padding: const EdgeInsets.all(10), child: Stack( children: [ - Align( - alignment: Alignment.topLeft, - child: DecoratedBox( - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Colors.white, - ), - child: IconButton( - padding: const EdgeInsets.all(12), - onPressed: () {}, - iconSize: 16, - icon: Icon( - Icons.arrow_back_ios_new_rounded, - color: Colors.grey.shade800, - ), - ), - ), - ), Column( mainAxisSize: MainAxisSize.min, children: [ @@ -156,34 +284,38 @@ class MusicPlayerView extends StatelessWidget { } class MusicMenuView extends StatelessWidget { - const MusicMenuView({super.key}); + const MusicMenuView({super.key, this.padding}); + + final EdgeInsets? padding; @override Widget build(BuildContext context) { - return SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Padding( - padding: EdgeInsets.only(bottom: 20), - child: _MusicMenuHeader(), - ), - Flexible( - child: ShaderMask( - shaderCallback: (bounds) => LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.white, - Colors.white, - Colors.white.withOpacity(0), - ], - stops: const [0, 0.8, 0.9], - ).createShader(bounds), + return ShaderMask( + shaderCallback: (bounds) => LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.white, + Colors.white, + Colors.white.withOpacity(0), + ], + stops: const [0, 0.9, 0.99], + ).createShader(bounds), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 20) + + (padding?.copyWith(bottom: 0) ?? EdgeInsets.zero), + child: const _MusicMenuHeader(), + ), + Flexible( child: ListView.separated( + physics: const NeverScrollableScrollPhysics(), shrinkWrap: true, - padding: const EdgeInsets.only(bottom: 100), + padding: padding?.copyWith(top: 0), itemBuilder: (context, pos) => _MusicMenuItem( title: _musicItems[pos]['title']!, artist: _musicItems[pos]['artist']!, @@ -196,8 +328,8 @@ class MusicMenuView extends StatelessWidget { itemCount: _musicItems.length, ), ), - ), - ], + ], + ), ), ); } @@ -225,6 +357,7 @@ class _MusicMenuHeader extends StatelessWidget { l10n.goodVibes, style: AesTextStyles.headlineLarge, overflow: TextOverflow.ellipsis, + maxLines: 2, ), const SizedBox(height: 10), Text( diff --git a/lib/overview/view/overview_page.dart b/lib/overview/view/overview_page.dart index 10b9923..e467db0 100644 --- a/lib/overview/view/overview_page.dart +++ b/lib/overview/view/overview_page.dart @@ -12,7 +12,7 @@ class OverviewPage extends StatelessWidget { return switch (layout) { AesLayoutData.small => const _SmallOverviewPage(), - AesLayoutData.large => const _LargeOverviewPage(), + AesLayoutData.medium || AesLayoutData.large => const _LargeOverviewPage(), }; } } @@ -22,33 +22,8 @@ class _SmallOverviewPage extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric( - vertical: 20, - ), - 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 const DashBoard( + padding: EdgeInsets.symmetric(vertical: 20, horizontal: 30), ); } } @@ -58,40 +33,50 @@ class _LargeOverviewPage extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric( - vertical: 20, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ + final showPlane = AesLayout.of(context) == AesLayoutData.large; + + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (showPlane) 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(), - ], - ), + const SizedBox(width: 80), + Expanded( + child: DashBoard( + padding: + const EdgeInsets.symmetric(vertical: 20).copyWith(right: 80), ), - ], - ), + ), + ], + ); + } +} + +class DashBoard extends StatelessWidget { + const DashBoard({super.key, this.padding}); + + final EdgeInsets? padding; + + @override + Widget build(BuildContext context) { + return ListView( + padding: padding, + children: const [ + WelcomeCopy(), + SizedBox(height: 40), + FlightTrackingCard(), + SizedBox(height: 20), + WeatherCard(), + SizedBox(height: 20), + MusicCard(), + SizedBox(height: 20), + MovieCard(), + ], ); } } diff --git a/packages/aes_ui/lib/src/widgets/aes_layout.dart b/packages/aes_ui/lib/src/widgets/aes_layout.dart index 2a19692..69134b4 100644 --- a/packages/aes_ui/lib/src/widgets/aes_layout.dart +++ b/packages/aes_ui/lib/src/widgets/aes_layout.dart @@ -9,17 +9,26 @@ enum AesLayoutData { /// Typically used for mobile devices. small, + /// A medium layout. + /// + /// Typically used for tablets. + medium, + /// A large layout. /// - /// Typically used for tablets and desktops. + /// Typically used for 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; + if (windowSize.width < windowSize.height || + windowSize.width < AesLayout.mobileBreakpoint) { + return AesLayoutData.small; + } + if (windowSize.width < AesLayout.desktopBreakpoint) { + return AesLayoutData.medium; + } + return AesLayoutData.large; } } @@ -40,9 +49,14 @@ class AesLayout extends StatelessWidget { super.key, }); - /// The threshold width at which the layout should change. + /// The threshold width at which the layout should change between small and + /// medium. static const double mobileBreakpoint = 600; + /// The threshold width at which the layout should change between medium and + /// large. + static const double desktopBreakpoint = 1000; + /// The layout to provide to the child. /// /// If `null` it is derived from the current size of the window. Otherwise, diff --git a/packages/aes_ui/test/src/widgets/aes_layout_test.dart b/packages/aes_ui/test/src/widgets/aes_layout_test.dart index 9244493..cc65f97 100644 --- a/packages/aes_ui/test/src/widgets/aes_layout_test.dart +++ b/packages/aes_ui/test/src/widgets/aes_layout_test.dart @@ -83,15 +83,41 @@ void main() { ); }); + group('is medium', () { + testWidgets( + 'when the width is smaller than the desktop breakpoint', + (tester) async { + late final BuildContext buildContext; + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData( + size: Size(AesLayout.desktopBreakpoint - 1, 200), + ), + child: AesLayout( + child: Builder( + builder: (context) { + buildContext = context; + return const SizedBox(); + }, + ), + ), + ), + ); + + expect(AesLayout.of(buildContext), equals(AesLayoutData.medium)); + }, + ); + }); + group('is large', () { testWidgets( - 'when the width is at the mobile breakpoint', + 'when the width is at the desktop breakpoint', (tester) async { late final BuildContext buildContext; await tester.pumpWidget( MediaQuery( data: const MediaQueryData( - size: Size(AesLayout.mobileBreakpoint, 200), + size: Size(AesLayout.desktopBreakpoint, 200), ), child: AesLayout( child: Builder( @@ -109,13 +135,13 @@ void main() { ); testWidgets( - 'when the width is greater than the mobile breakpoint', + 'when the width is greater than the desktop breakpoint', (tester) async { late final BuildContext buildContext; await tester.pumpWidget( MediaQuery( data: const MediaQueryData( - size: Size(AesLayout.mobileBreakpoint + 1, 200), + size: Size(AesLayout.desktopBreakpoint + 1, 200), ), child: AesLayout( child: Builder( @@ -169,7 +195,7 @@ void main() { }); await tester.pumpAndSettle(); - expect(AesLayout.of(buildContext!), equals(AesLayoutData.large)); + expect(AesLayout.of(buildContext!), equals(AesLayoutData.medium)); }, ); }); diff --git a/test/helpers/pump_experience.dart b/test/helpers/pump_experience.dart index fac6eaa..3700d36 100644 --- a/test/helpers/pump_experience.dart +++ b/test/helpers/pump_experience.dart @@ -4,7 +4,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; extension PumpApp on WidgetTester { - Future pumpApp(Widget widget, {AesLayoutData? layout}) { + Future pumpApp(Widget widget, {AesLayoutData? layout}) async { + if (layout == AesLayoutData.large) { + await binding.setSurfaceSize(const Size(1600, 1200)); + addTearDown(() => binding.setSurfaceSize(null)); + } + return pumpWidget( AesLayout( data: layout, diff --git a/test/music_player/view/music_player_page_test.dart b/test/music_player/view/music_player_page_test.dart index aaf6a3c..ca09b7e 100644 --- a/test/music_player/view/music_player_page_test.dart +++ b/test/music_player/view/music_player_page_test.dart @@ -1,3 +1,4 @@ +import 'package:aes_ui/aes_ui.dart'; import 'package:airplane_entertainment_system/music_player/view/view.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -17,16 +18,25 @@ void main() { expect(find.byType(MusicPlayerView), findsOneWidget); }); - testWidgets('contains back button', (tester) async { + testWidgets('when screen size is small, player is shown in a bottom sheet', + (tester) async { await tester.pumpApp( const Scaffold( body: MusicPlayerPage(), ), + layout: AesLayoutData.small, ); - expect(find.byIcon(Icons.arrow_back_ios_new_rounded), findsOneWidget); + final playerFinder = find.byType(MusicPlayerView); + expect(playerFinder, findsNothing); - await tester.tap(find.byIcon(Icons.arrow_back_ios_new_rounded)); + final buttonFinder = find.byType(MusicFloatingButton); + expect(buttonFinder, findsOneWidget); + + await tester.tap(buttonFinder); + await tester.pumpAndSettle(); + + expect(playerFinder, findsOneWidget); }); testWidgets('contains slider and changing it does nothing', (tester) async {