// Copyright 2014 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 'dart:ui' as ui; import 'package:animations/animations.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:provider/provider.dart'; import '../../data/gallery_options.dart'; import '../../gallery_localizations.dart'; import '../../layout/adaptive.dart'; import 'app.dart'; import 'bottom_drawer.dart'; import 'colors.dart'; import 'compose_page.dart'; import 'mailbox_body.dart'; import 'model/email_model.dart'; import 'model/email_store.dart'; import 'profile_avatar.dart'; import 'search_page.dart'; import 'waterfall_notched_rectangle.dart'; const String _assetsPackage = 'flutter_gallery_assets'; const String _iconAssetLocation = 'reply/icons'; const String _folderIconAssetLocation = '$_iconAssetLocation/twotone_folder.png'; final GlobalKey desktopMailNavKey = GlobalKey(); final GlobalKey mobileMailNavKey = GlobalKey(); const double _kFlingVelocity = 2.0; const Duration _kAnimationDuration = Duration(milliseconds: 300); class AdaptiveNav extends StatefulWidget { const AdaptiveNav({super.key}); @override State createState() => _AdaptiveNavState(); } class _AdaptiveNavState extends State { @override void initState() { super.initState(); } @override Widget build(BuildContext context) { final bool isDesktop = isDisplayDesktop(context); final bool isTablet = isDisplaySmallDesktop(context); final GalleryLocalizations localizations = GalleryLocalizations.of(context)!; final navigationDestinations = <_Destination>[ _Destination( type: MailboxPageType.inbox, textLabel: localizations.replyInboxLabel, icon: '$_iconAssetLocation/twotone_inbox.png', ), _Destination( type: MailboxPageType.starred, textLabel: localizations.replyStarredLabel, icon: '$_iconAssetLocation/twotone_star.png', ), _Destination( type: MailboxPageType.sent, textLabel: localizations.replySentLabel, icon: '$_iconAssetLocation/twotone_send.png', ), _Destination( type: MailboxPageType.trash, textLabel: localizations.replyTrashLabel, icon: '$_iconAssetLocation/twotone_delete.png', ), _Destination( type: MailboxPageType.spam, textLabel: localizations.replySpamLabel, icon: '$_iconAssetLocation/twotone_error.png', ), _Destination( type: MailboxPageType.drafts, textLabel: localizations.replyDraftsLabel, icon: '$_iconAssetLocation/twotone_drafts.png', ), ]; final folders = { 'Receipts': _folderIconAssetLocation, 'Pine Elementary': _folderIconAssetLocation, 'Taxes': _folderIconAssetLocation, 'Vacation': _folderIconAssetLocation, 'Mortgage': _folderIconAssetLocation, 'Freelance': _folderIconAssetLocation, }; if (isDesktop) { return _DesktopNav( extended: !isTablet, destinations: navigationDestinations, folders: folders, onItemTapped: _onDestinationSelected, ); } else { return _MobileNav( destinations: navigationDestinations, folders: folders, onItemTapped: _onDestinationSelected, ); } } void _onDestinationSelected(int index, MailboxPageType destination) { final EmailStore emailStore = Provider.of(context, listen: false); final bool isDesktop = isDisplayDesktop(context); emailStore.selectedMailboxPage = destination; if (isDesktop) { while (desktopMailNavKey.currentState!.canPop()) { desktopMailNavKey.currentState!.pop(); } } if (emailStore.onMailView) { if (!isDesktop) { mobileMailNavKey.currentState!.pop(); } emailStore.selectedEmailId = -1; } } } class _DesktopNav extends StatefulWidget { const _DesktopNav({ required this.extended, required this.destinations, required this.folders, required this.onItemTapped, }); final bool extended; final List<_Destination> destinations; final Map folders; final void Function(int, MailboxPageType) onItemTapped; @override _DesktopNavState createState() => _DesktopNavState(); } class _DesktopNavState extends State<_DesktopNav> with SingleTickerProviderStateMixin { late ValueNotifier _isExtended; @override void initState() { super.initState(); _isExtended = ValueNotifier(widget.extended); } @override Widget build(BuildContext context) { return Scaffold( body: Row( children: [ Consumer( builder: (BuildContext context, EmailStore model, Widget? child) { return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { final int selectedIndex = widget.destinations.indexWhere(( _Destination destination, ) { return destination.type == model.selectedMailboxPage; }); return Container( color: Theme.of(context).navigationRailTheme.backgroundColor, child: SingleChildScrollView( clipBehavior: Clip.antiAlias, child: ConstrainedBox( constraints: BoxConstraints(minHeight: constraints.maxHeight), child: IntrinsicHeight( child: ValueListenableBuilder( valueListenable: _isExtended, builder: (BuildContext context, bool value, Widget? child) { return NavigationRail( destinations: [ for (final _Destination destination in widget.destinations) NavigationRailDestination( icon: Material( key: ValueKey('Reply-${destination.textLabel}'), color: Colors.transparent, child: ImageIcon( AssetImage(destination.icon, package: _assetsPackage), ), ), label: Text(destination.textLabel), ), ], extended: _isExtended.value, labelType: NavigationRailLabelType.none, leading: _NavigationRailHeader(extended: _isExtended), trailing: _NavigationRailFolderSection(folders: widget.folders), selectedIndex: selectedIndex, onDestinationSelected: (int index) { widget.onItemTapped(index, widget.destinations[index].type); }, ); }, ), ), ), ), ); }, ); }, ), const VerticalDivider(thickness: 1, width: 1), Expanded( child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 1340), child: const _SharedAxisTransitionSwitcher( defaultChild: _MailNavigator(child: MailboxBody()), ), ), ), ), ], ), ); } } class _NavigationRailHeader extends StatelessWidget { const _NavigationRailHeader({required this.extended}); final ValueNotifier extended; @override Widget build(BuildContext context) { final TextTheme textTheme = Theme.of(context).textTheme; final Animation animation = NavigationRail.extendedAnimation(context); return AnimatedBuilder( animation: animation, builder: (BuildContext context, Widget? child) { return Align( alignment: AlignmentDirectional.centerStart, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( height: 56, child: Row( children: [ const SizedBox(width: 6), InkWell( key: const ValueKey('ReplyLogo'), borderRadius: const BorderRadius.all(Radius.circular(16)), onTap: () { extended.value = !extended.value; }, child: Row( children: [ Transform.rotate( angle: animation.value * math.pi, child: const Icon( Icons.arrow_left, color: ReplyColors.white50, size: 16, ), ), const _ReplyLogo(), const SizedBox(width: 10), Align( alignment: AlignmentDirectional.centerStart, widthFactor: animation.value, child: Opacity( opacity: animation.value, child: Text( 'REPLY', style: textTheme.bodyLarge!.copyWith(color: ReplyColors.white50), ), ), ), SizedBox(width: 18 * animation.value), ], ), ), if (animation.value > 0) Opacity( opacity: animation.value, child: const Row( children: [ SizedBox(width: 18), ProfileAvatar(avatar: 'reply/avatars/avatar_2.jpg', radius: 16), SizedBox(width: 12), Icon(Icons.settings, color: ReplyColors.white50), ], ), ), ], ), ), const SizedBox(height: 20), Padding( padding: const EdgeInsetsDirectional.only(start: 8), child: _ReplyFab(extended: extended.value), ), const SizedBox(height: 8), ], ), ); }, ); } } class _NavigationRailFolderSection extends StatelessWidget { const _NavigationRailFolderSection({required this.folders}); final Map folders; @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); final TextTheme textTheme = theme.textTheme; final NavigationRailThemeData navigationRailTheme = theme.navigationRailTheme; final Animation animation = NavigationRail.extendedAnimation(context); return AnimatedBuilder( animation: animation, builder: (BuildContext context, Widget? child) { return Visibility( maintainAnimation: true, maintainState: true, visible: animation.value > 0, child: Opacity( opacity: animation.value, child: Align( widthFactor: animation.value, alignment: AlignmentDirectional.centerStart, child: SizedBox( height: 485, width: 256, child: ListView( padding: const EdgeInsets.all(12), physics: const NeverScrollableScrollPhysics(), children: [ const Divider( color: ReplyColors.blue200, thickness: 0.4, indent: 14, endIndent: 16, ), const SizedBox(height: 16), Padding( padding: const EdgeInsetsDirectional.only(start: 16), child: Text( 'FOLDERS', style: textTheme.bodySmall!.copyWith( color: navigationRailTheme.unselectedLabelTextStyle!.color, ), ), ), const SizedBox(height: 8), for (final String folder in folders.keys) InkWell( borderRadius: const BorderRadius.all(Radius.circular(36)), onTap: () {}, child: Column( children: [ Row( children: [ const SizedBox(width: 12), ImageIcon( AssetImage(folders[folder]!, package: _assetsPackage), color: navigationRailTheme.unselectedLabelTextStyle!.color, ), const SizedBox(width: 24), Text( folder, style: textTheme.bodyLarge!.copyWith( color: navigationRailTheme.unselectedLabelTextStyle!.color, ), ), const SizedBox(height: 72), ], ), ], ), ), ], ), ), ), ), ); }, ); } } class _MobileNav extends StatefulWidget { const _MobileNav({required this.destinations, required this.folders, required this.onItemTapped}); final List<_Destination> destinations; final Map folders; final void Function(int, MailboxPageType) onItemTapped; @override _MobileNavState createState() => _MobileNavState(); } class _MobileNavState extends State<_MobileNav> with TickerProviderStateMixin { final GlobalKey> _bottomDrawerKey = GlobalKey(debugLabel: 'Bottom Drawer'); late AnimationController _drawerController; late AnimationController _dropArrowController; late AnimationController _bottomAppBarController; late Animation _drawerCurve; late Animation _dropArrowCurve; late Animation _bottomAppBarCurve; @override void initState() { super.initState(); _drawerController = AnimationController(duration: _kAnimationDuration, value: 0, vsync: this) ..addListener(() { if (_drawerController.value < 0.01) { setState(() { //Reload state when drawer is at its smallest to toggle visibility //If state is reloaded before this drawer closes abruptly instead //of animating. }); } }); _dropArrowController = AnimationController(duration: _kAnimationDuration, vsync: this); _bottomAppBarController = AnimationController( vsync: this, value: 1, duration: const Duration(milliseconds: 250), ); _drawerCurve = CurvedAnimation( parent: _drawerController, curve: Easing.legacy, reverseCurve: Easing.legacy.flipped, ); _dropArrowCurve = CurvedAnimation( parent: _dropArrowController, curve: Easing.legacy, reverseCurve: Easing.legacy.flipped, ); _bottomAppBarCurve = CurvedAnimation( parent: _bottomAppBarController, curve: Easing.legacy, reverseCurve: Easing.legacy.flipped, ); } @override void dispose() { _drawerController.dispose(); _dropArrowController.dispose(); _bottomAppBarController.dispose(); super.dispose(); } bool get _bottomDrawerVisible => _drawerController.isForwardOrCompleted; void _toggleBottomDrawerVisibility() { if (_drawerController.value < 0.4) { _drawerController.animateTo(0.4, curve: Easing.legacy); _dropArrowController.animateTo(0.35, curve: Easing.legacy); return; } _dropArrowController.forward(); _drawerController.fling(velocity: _bottomDrawerVisible ? -_kFlingVelocity : _kFlingVelocity); } double get _bottomDrawerHeight { final renderBox = _bottomDrawerKey.currentContext!.findRenderObject()! as RenderBox; return renderBox.size.height; } void _handleDragUpdate(DragUpdateDetails details) { _drawerController.value -= details.primaryDelta! / _bottomDrawerHeight; } void _handleDragEnd(DragEndDetails details) { if (!_drawerController.isDismissed) { return; } final double flingVelocity = details.velocity.pixelsPerSecond.dy / _bottomDrawerHeight; if (flingVelocity < 0.0) { _drawerController.fling(velocity: math.max(_kFlingVelocity, -flingVelocity)); } else if (flingVelocity > 0.0) { _dropArrowController.forward(); _drawerController.fling(velocity: math.min(-_kFlingVelocity, -flingVelocity)); } else { if (_drawerController.value < 0.6) { _dropArrowController.forward(); } _drawerController.fling( velocity: _drawerController.value < 0.6 ? -_kFlingVelocity : _kFlingVelocity, ); } } bool _handleScrollNotification(ScrollNotification notification) { if (notification case UserScrollNotification(depth: 0)) { switch (notification.direction) { case ScrollDirection.forward: _bottomAppBarController.forward(); case ScrollDirection.reverse: _bottomAppBarController.reverse(); case ScrollDirection.idle: break; } } return false; } Widget _buildStack(BuildContext context, BoxConstraints constraints) { final ui.Size drawerSize = constraints.biggest; final double drawerTop = drawerSize.height; final Animation drawerAnimation = RelativeRectTween( begin: RelativeRect.fromLTRB(0.0, drawerTop, 0.0, 0.0), end: RelativeRect.fill, ).animate(_drawerCurve); return Stack( clipBehavior: Clip.none, key: _bottomDrawerKey, children: [ NotificationListener( onNotification: _handleScrollNotification, child: const _MailNavigator(child: MailboxBody()), ), MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( onTap: () { _drawerController.reverse(); _dropArrowController.reverse(); }, child: Visibility( maintainAnimation: true, maintainState: true, visible: _bottomDrawerVisible, child: FadeTransition( opacity: _drawerCurve, child: Container( height: MediaQuery.heightOf(context), width: MediaQuery.widthOf(context), color: Theme.of(context).bottomSheetTheme.modalBackgroundColor, ), ), ), ), ), PositionedTransition( rect: drawerAnimation, child: Visibility( visible: _bottomDrawerVisible, child: BottomDrawer( onVerticalDragUpdate: _handleDragUpdate, onVerticalDragEnd: _handleDragEnd, leading: Consumer( builder: (BuildContext context, EmailStore model, Widget? child) { return _BottomDrawerDestinations( destinations: widget.destinations, drawerController: _drawerController, dropArrowController: _dropArrowController, selectedMailbox: model.selectedMailboxPage, onItemTapped: widget.onItemTapped, ); }, ), trailing: _BottomDrawerFolderSection(folders: widget.folders), ), ), ), ], ); } @override Widget build(BuildContext context) { return _SharedAxisTransitionSwitcher( defaultChild: Scaffold( extendBody: true, body: LayoutBuilder(builder: _buildStack), bottomNavigationBar: Consumer( builder: (BuildContext context, EmailStore model, Widget? child) { return _AnimatedBottomAppBar( bottomAppBarController: _bottomAppBarController, bottomAppBarCurve: _bottomAppBarCurve, bottomDrawerVisible: _bottomDrawerVisible, drawerController: _drawerController, dropArrowCurve: _dropArrowCurve, navigationDestinations: widget.destinations, selectedMailbox: model.selectedMailboxPage, toggleBottomDrawerVisibility: _toggleBottomDrawerVisibility, ); }, ), floatingActionButton: _bottomDrawerVisible ? null : const Padding(padding: EdgeInsetsDirectional.only(bottom: 8), child: _ReplyFab()), floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, ), ); } } class _AnimatedBottomAppBar extends StatelessWidget { const _AnimatedBottomAppBar({ required this.bottomAppBarController, required this.bottomAppBarCurve, required this.bottomDrawerVisible, required this.drawerController, required this.dropArrowCurve, required this.navigationDestinations, this.selectedMailbox, this.toggleBottomDrawerVisibility, }); final AnimationController bottomAppBarController; final Animation bottomAppBarCurve; final bool bottomDrawerVisible; final AnimationController drawerController; final Animation dropArrowCurve; final List<_Destination> navigationDestinations; final MailboxPageType? selectedMailbox; final ui.VoidCallback? toggleBottomDrawerVisibility; @override Widget build(BuildContext context) { final Animation fadeOut = Tween( begin: 1, end: -1, ).animate(drawerController.drive(CurveTween(curve: Easing.legacy))); return Selector( selector: (BuildContext context, EmailStore emailStore) => emailStore.onMailView, builder: (BuildContext context, bool onMailView, Widget? child) { bottomAppBarController.forward(); return SizeTransition( sizeFactor: bottomAppBarCurve, alignment: Alignment.topLeft, child: Padding( padding: const EdgeInsetsDirectional.only(top: 2), child: BottomAppBar( shape: const WaterfallNotchedRectangle(), notchMargin: 6, child: Container( color: Colors.transparent, height: kToolbarHeight, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ InkWell( key: const ValueKey('navigation_button'), borderRadius: const BorderRadius.all(Radius.circular(16)), onTap: toggleBottomDrawerVisibility, child: Row( children: [ const SizedBox(width: 16), RotationTransition( turns: Tween(begin: 0.0, end: 1.0).animate(dropArrowCurve), child: const Icon(Icons.arrow_drop_up, color: ReplyColors.white50), ), const SizedBox(width: 8), const _ReplyLogo(), const SizedBox(width: 10), _FadeThroughTransitionSwitcher( fillColor: Colors.transparent, child: onMailView ? const SizedBox(width: 48) : FadeTransition( opacity: fadeOut, child: Text( navigationDestinations.firstWhere((_Destination destination) { return destination.type == selectedMailbox; }).textLabel, style: Theme.of( context, ).textTheme.bodyLarge!.copyWith(color: ReplyColors.white50), ), ), ), ], ), ), Expanded( child: ColoredBox( color: Colors.transparent, child: _BottomAppBarActionItems(drawerVisible: bottomDrawerVisible), ), ), ], ), ), ), ), ); }, ); } } class _BottomAppBarActionItems extends StatelessWidget { const _BottomAppBarActionItems({required this.drawerVisible}); final bool drawerVisible; @override Widget build(BuildContext context) { return Consumer( builder: (BuildContext context, EmailStore model, Widget? child) { final bool onMailView = model.onMailView; Color? starIconColor; if (onMailView) { starIconColor = model.isCurrentEmailStarred ? Theme.of(context).colorScheme.secondary : ReplyColors.white50; } return _FadeThroughTransitionSwitcher( fillColor: Colors.transparent, child: drawerVisible ? Align( key: UniqueKey(), alignment: Alignment.centerRight, child: IconButton( icon: const Icon(Icons.settings), color: ReplyColors.white50, onPressed: () {}, ), ) : onMailView ? Row( mainAxisAlignment: MainAxisAlignment.end, children: [ IconButton( key: const ValueKey('star_email_button'), icon: ImageIcon( const AssetImage( '$_iconAssetLocation/twotone_star.png', package: _assetsPackage, ), color: starIconColor, ), onPressed: () { final Email currentEmail = model.currentEmail; if (model.isCurrentEmailStarred) { model.unstarEmail(currentEmail.id); } else { model.starEmail(currentEmail.id); } if (model.selectedMailboxPage == MailboxPageType.starred) { mobileMailNavKey.currentState!.pop(); model.selectedEmailId = -1; } }, color: ReplyColors.white50, ), IconButton( icon: const ImageIcon( AssetImage( '$_iconAssetLocation/twotone_delete.png', package: _assetsPackage, ), ), onPressed: () { model.deleteEmail(model.selectedEmailId); mobileMailNavKey.currentState!.pop(); model.selectedEmailId = -1; }, color: ReplyColors.white50, ), IconButton( icon: const Icon(Icons.more_vert), onPressed: () {}, color: ReplyColors.white50, ), ], ) : Align( alignment: Alignment.centerRight, child: IconButton( key: const ValueKey('ReplySearch'), icon: const Icon(Icons.search), color: ReplyColors.white50, onPressed: () { Provider.of(context, listen: false).onSearchPage = true; }, ), ), ); }, ); } } class _BottomDrawerDestinations extends StatelessWidget { const _BottomDrawerDestinations({ required this.destinations, required this.drawerController, required this.dropArrowController, required this.selectedMailbox, required this.onItemTapped, }); final List<_Destination> destinations; final AnimationController drawerController; final AnimationController dropArrowController; final MailboxPageType selectedMailbox; final void Function(int, MailboxPageType) onItemTapped; @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); final destinationButtons = []; for (var index = 0; index < destinations.length; index += 1) { final _Destination destination = destinations[index]; destinationButtons.add( InkWell( key: ValueKey('Reply-${destination.textLabel}'), onTap: () { drawerController.reverse(); dropArrowController.forward(); Future.delayed( Duration( milliseconds: (drawerController.value == 1 ? 300 : 120) * GalleryOptions.of(context).timeDilation.toInt(), ), () { // Wait until animations are complete to reload the state. // Delay scales with the timeDilation value of the gallery. onItemTapped(index, destination.type); }, ); }, child: ListTile( mouseCursor: SystemMouseCursors.click, leading: ImageIcon( AssetImage(destination.icon, package: _assetsPackage), color: destination.type == selectedMailbox ? theme.colorScheme.secondary : theme.navigationRailTheme.unselectedLabelTextStyle!.color, ), title: Text( destination.textLabel, style: theme.textTheme.bodyMedium!.copyWith( color: destination.type == selectedMailbox ? theme.colorScheme.secondary : theme.navigationRailTheme.unselectedLabelTextStyle!.color, ), ), ), ), ); } return Column(children: destinationButtons); } } class _Destination { const _Destination({required this.type, required this.textLabel, required this.icon}); // Which mailbox page to display. For example, 'Starred' or 'Trash'. final MailboxPageType type; // The localized text label for the inbox. final String textLabel; // The icon that appears next to the text label for the inbox. final String icon; } class _BottomDrawerFolderSection extends StatelessWidget { const _BottomDrawerFolderSection({required this.folders}); final Map folders; @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); final NavigationRailThemeData navigationRailTheme = theme.navigationRailTheme; return Column( children: [ for (final String folder in folders.keys) InkWell( onTap: () {}, child: ListTile( mouseCursor: SystemMouseCursors.click, leading: ImageIcon( AssetImage(folders[folder]!, package: _assetsPackage), color: navigationRailTheme.unselectedLabelTextStyle!.color, ), title: Text( folder, style: theme.textTheme.bodyMedium!.copyWith( color: navigationRailTheme.unselectedLabelTextStyle!.color, ), ), ), ), ], ); } } class _MailNavigator extends StatefulWidget { const _MailNavigator({required this.child}); final Widget child; @override _MailNavigatorState createState() => _MailNavigatorState(); } class _MailNavigatorState extends State<_MailNavigator> { static const String inboxRoute = '/reply/inbox'; @override Widget build(BuildContext context) { final bool isDesktop = isDisplayDesktop(context); return Navigator( restorationScopeId: 'replyMailNavigator', key: isDesktop ? desktopMailNavKey : mobileMailNavKey, initialRoute: inboxRoute, onGenerateRoute: (RouteSettings settings) { switch (settings.name) { case inboxRoute: return MaterialPageRoute( builder: (BuildContext context) { return _FadeThroughTransitionSwitcher( fillColor: Theme.of(context).scaffoldBackgroundColor, child: widget.child, ); }, settings: settings, ); case ReplyApp.composeRoute: return ReplyApp.createComposeRoute(settings); } return null; }, ); } } class _ReplyLogo extends StatelessWidget { const _ReplyLogo(); @override Widget build(BuildContext context) { return const ImageIcon( AssetImage('reply/reply_logo.png', package: _assetsPackage), size: 32, color: ReplyColors.white50, ); } } class _ReplyFab extends StatefulWidget { const _ReplyFab({this.extended = false}); final bool extended; @override _ReplyFabState createState() => _ReplyFabState(); } class _ReplyFabState extends State<_ReplyFab> with SingleTickerProviderStateMixin { static final UniqueKey fabKey = UniqueKey(); static const double _mobileFabDimension = 56; void onPressed() { final bool onSearchPage = Provider.of(context, listen: false).onSearchPage; // Navigator does not have an easy way to access the current // route when using a GlobalKey to keep track of NavigatorState. // We can use [Navigator.popUntil] in order to access the current // route, and check if it is a ComposePage. If it is not a // ComposePage and we are not on the SearchPage, then we can push // a ComposePage onto our navigator. We return true at the end // so nothing is popped. desktopMailNavKey.currentState!.popUntil((Route route) { final String? currentRoute = route.settings.name; if (currentRoute != ReplyApp.composeRoute && !onSearchPage) { desktopMailNavKey.currentState!.restorablePushNamed(ReplyApp.composeRoute); } return true; }); } @override Widget build(BuildContext context) { final bool isDesktop = isDisplayDesktop(context); final ThemeData theme = Theme.of(context); const circleFabBorder = CircleBorder(); return Selector( selector: (BuildContext context, EmailStore emailStore) => emailStore.onMailView, builder: (BuildContext context, bool onMailView, Widget? child) { final fabSwitcher = _FadeThroughTransitionSwitcher( fillColor: Colors.transparent, child: onMailView ? Icon(Icons.reply_all, key: fabKey, color: Colors.black) : const Icon(Icons.create, color: Colors.black), ); final tooltip = onMailView ? 'Reply' : 'Compose'; if (isDesktop) { final Animation animation = NavigationRail.extendedAnimation(context); return Container( height: 56, padding: EdgeInsets.symmetric(vertical: ui.lerpDouble(0, 6, animation.value)!), child: animation.value == 0 ? FloatingActionButton( tooltip: tooltip, key: const ValueKey('ReplyFab'), onPressed: onPressed, child: fabSwitcher, ) : Align( alignment: AlignmentDirectional.centerStart, child: FloatingActionButton.extended( key: const ValueKey('ReplyFab'), label: Row( children: [ fabSwitcher, SizedBox(width: 16 * animation.value), Align( alignment: AlignmentDirectional.centerStart, widthFactor: animation.value, child: Text( tooltip.toUpperCase(), style: Theme.of(context).textTheme.headlineSmall!.copyWith( fontSize: 16, color: theme.colorScheme.onSecondary, ), ), ), ], ), onPressed: onPressed, ), ), ); } else { // TODO(x): State restoration of compose page on mobile is blocked because OpenContainer does not support restorablePush, https://github.com/flutter/gallery/issues/570. return OpenContainer( openBuilder: (BuildContext context, void Function() closedContainer) { return const ComposePage(); }, openColor: theme.cardColor, closedShape: circleFabBorder, closedColor: theme.colorScheme.secondary, closedElevation: 6, closedBuilder: (BuildContext context, void Function() openContainer) { return Tooltip( message: tooltip, child: InkWell( key: const ValueKey('ReplyFab'), customBorder: circleFabBorder, onTap: openContainer, child: SizedBox.square( dimension: _mobileFabDimension, child: Center(child: fabSwitcher), ), ), ); }, ); } }, ); } } class _FadeThroughTransitionSwitcher extends StatelessWidget { const _FadeThroughTransitionSwitcher({required this.fillColor, required this.child}); final Widget child; final Color fillColor; @override Widget build(BuildContext context) { return PageTransitionSwitcher( transitionBuilder: (Widget child, Animation animation, Animation secondaryAnimation) { return FadeThroughTransition( fillColor: fillColor, animation: animation, secondaryAnimation: secondaryAnimation, child: child, ); }, child: child, ); } } class _SharedAxisTransitionSwitcher extends StatelessWidget { const _SharedAxisTransitionSwitcher({required this.defaultChild}); final Widget defaultChild; @override Widget build(BuildContext context) { return Selector( selector: (BuildContext context, EmailStore emailStore) => emailStore.onSearchPage, builder: (BuildContext context, bool onSearchPage, Widget? child) { return PageTransitionSwitcher( reverse: !onSearchPage, transitionBuilder: (Widget child, Animation animation, Animation secondaryAnimation) { return SharedAxisTransition( fillColor: Theme.of(context).colorScheme.background, animation: animation, secondaryAnimation: secondaryAnimation, transitionType: SharedAxisTransitionType.scaled, child: child, ); }, child: onSearchPage ? const SearchPage() : defaultChild, ); }, ); } }