1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'dart:math' as math;
6import 'dart:ui' as ui;
7
8import 'package:animations/animations.dart';
9import 'package:flutter/material.dart';
10import 'package:flutter/rendering.dart';
11import 'package:provider/provider.dart';
12
13import '../../data/gallery_options.dart';
14import '../../gallery_localizations.dart';
15import '../../layout/adaptive.dart';
16import 'app.dart';
17import 'bottom_drawer.dart';
18import 'colors.dart';
19import 'compose_page.dart';
20import 'mailbox_body.dart';
21import 'model/email_model.dart';
22import 'model/email_store.dart';
23import 'profile_avatar.dart';
24import 'search_page.dart';
25import 'waterfall_notched_rectangle.dart';
26
27const String _assetsPackage = 'flutter_gallery_assets';
28const String _iconAssetLocation = 'reply/icons';
29const String _folderIconAssetLocation = '$_iconAssetLocation/twotone_folder.png';
30final GlobalKey<NavigatorState> desktopMailNavKey = GlobalKey<NavigatorState>();
31final GlobalKey<NavigatorState> mobileMailNavKey = GlobalKey<NavigatorState>();
32const double _kFlingVelocity = 2.0;
33const Duration _kAnimationDuration = Duration(milliseconds: 300);
34
35class AdaptiveNav extends StatefulWidget {
36 const AdaptiveNav({super.key});
37
38 @override
39 State<AdaptiveNav> createState() => _AdaptiveNavState();
40}
41
42class _AdaptiveNavState extends State<AdaptiveNav> {
43 @override
44 void initState() {
45 super.initState();
46 }
47
48 @override
49 Widget build(BuildContext context) {
50 final bool isDesktop = isDisplayDesktop(context);
51 final bool isTablet = isDisplaySmallDesktop(context);
52 final GalleryLocalizations localizations = GalleryLocalizations.of(context)!;
53 final List<_Destination> navigationDestinations = <_Destination>[
54 _Destination(
55 type: MailboxPageType.inbox,
56 textLabel: localizations.replyInboxLabel,
57 icon: '$_iconAssetLocation/twotone_inbox.png',
58 ),
59 _Destination(
60 type: MailboxPageType.starred,
61 textLabel: localizations.replyStarredLabel,
62 icon: '$_iconAssetLocation/twotone_star.png',
63 ),
64 _Destination(
65 type: MailboxPageType.sent,
66 textLabel: localizations.replySentLabel,
67 icon: '$_iconAssetLocation/twotone_send.png',
68 ),
69 _Destination(
70 type: MailboxPageType.trash,
71 textLabel: localizations.replyTrashLabel,
72 icon: '$_iconAssetLocation/twotone_delete.png',
73 ),
74 _Destination(
75 type: MailboxPageType.spam,
76 textLabel: localizations.replySpamLabel,
77 icon: '$_iconAssetLocation/twotone_error.png',
78 ),
79 _Destination(
80 type: MailboxPageType.drafts,
81 textLabel: localizations.replyDraftsLabel,
82 icon: '$_iconAssetLocation/twotone_drafts.png',
83 ),
84 ];
85
86 final Map<String, String> folders = <String, String>{
87 'Receipts': _folderIconAssetLocation,
88 'Pine Elementary': _folderIconAssetLocation,
89 'Taxes': _folderIconAssetLocation,
90 'Vacation': _folderIconAssetLocation,
91 'Mortgage': _folderIconAssetLocation,
92 'Freelance': _folderIconAssetLocation,
93 };
94
95 if (isDesktop) {
96 return _DesktopNav(
97 extended: !isTablet,
98 destinations: navigationDestinations,
99 folders: folders,
100 onItemTapped: _onDestinationSelected,
101 );
102 } else {
103 return _MobileNav(
104 destinations: navigationDestinations,
105 folders: folders,
106 onItemTapped: _onDestinationSelected,
107 );
108 }
109 }
110
111 void _onDestinationSelected(int index, MailboxPageType destination) {
112 final EmailStore emailStore = Provider.of<EmailStore>(context, listen: false);
113
114 final bool isDesktop = isDisplayDesktop(context);
115
116 emailStore.selectedMailboxPage = destination;
117
118 if (isDesktop) {
119 while (desktopMailNavKey.currentState!.canPop()) {
120 desktopMailNavKey.currentState!.pop();
121 }
122 }
123
124 if (emailStore.onMailView) {
125 if (!isDesktop) {
126 mobileMailNavKey.currentState!.pop();
127 }
128
129 emailStore.selectedEmailId = -1;
130 }
131 }
132}
133
134class _DesktopNav extends StatefulWidget {
135 const _DesktopNav({
136 required this.extended,
137 required this.destinations,
138 required this.folders,
139 required this.onItemTapped,
140 });
141
142 final bool extended;
143 final List<_Destination> destinations;
144 final Map<String, String> folders;
145 final void Function(int, MailboxPageType) onItemTapped;
146
147 @override
148 _DesktopNavState createState() => _DesktopNavState();
149}
150
151class _DesktopNavState extends State<_DesktopNav> with SingleTickerProviderStateMixin {
152 late ValueNotifier<bool> _isExtended;
153
154 @override
155 void initState() {
156 super.initState();
157 _isExtended = ValueNotifier<bool>(widget.extended);
158 }
159
160 @override
161 Widget build(BuildContext context) {
162 return Scaffold(
163 body: Row(
164 children: <Widget>[
165 Consumer<EmailStore>(
166 builder: (BuildContext context, EmailStore model, Widget? child) {
167 return LayoutBuilder(
168 builder: (BuildContext context, BoxConstraints constraints) {
169 final int selectedIndex = widget.destinations.indexWhere((
170 _Destination destination,
171 ) {
172 return destination.type == model.selectedMailboxPage;
173 });
174 return Container(
175 color: Theme.of(context).navigationRailTheme.backgroundColor,
176 child: SingleChildScrollView(
177 clipBehavior: Clip.antiAlias,
178 child: ConstrainedBox(
179 constraints: BoxConstraints(minHeight: constraints.maxHeight),
180 child: IntrinsicHeight(
181 child: ValueListenableBuilder<bool>(
182 valueListenable: _isExtended,
183 builder: (BuildContext context, bool value, Widget? child) {
184 return NavigationRail(
185 destinations: <NavigationRailDestination>[
186 for (final _Destination destination in widget.destinations)
187 NavigationRailDestination(
188 icon: Material(
189 key: ValueKey<String>('Reply-${destination.textLabel}'),
190 color: Colors.transparent,
191 child: ImageIcon(
192 AssetImage(destination.icon, package: _assetsPackage),
193 ),
194 ),
195 label: Text(destination.textLabel),
196 ),
197 ],
198 extended: _isExtended.value,
199 labelType: NavigationRailLabelType.none,
200 leading: _NavigationRailHeader(extended: _isExtended),
201 trailing: _NavigationRailFolderSection(folders: widget.folders),
202 selectedIndex: selectedIndex,
203 onDestinationSelected: (int index) {
204 widget.onItemTapped(index, widget.destinations[index].type);
205 },
206 );
207 },
208 ),
209 ),
210 ),
211 ),
212 );
213 },
214 );
215 },
216 ),
217 const VerticalDivider(thickness: 1, width: 1),
218 Expanded(
219 child: Center(
220 child: ConstrainedBox(
221 constraints: const BoxConstraints(maxWidth: 1340),
222 child: const _SharedAxisTransitionSwitcher(
223 defaultChild: _MailNavigator(child: MailboxBody()),
224 ),
225 ),
226 ),
227 ),
228 ],
229 ),
230 );
231 }
232}
233
234class _NavigationRailHeader extends StatelessWidget {
235 const _NavigationRailHeader({required this.extended});
236
237 final ValueNotifier<bool> extended;
238
239 @override
240 Widget build(BuildContext context) {
241 final TextTheme textTheme = Theme.of(context).textTheme;
242 final Animation<double> animation = NavigationRail.extendedAnimation(context);
243
244 return AnimatedBuilder(
245 animation: animation,
246 builder: (BuildContext context, Widget? child) {
247 return Align(
248 alignment: AlignmentDirectional.centerStart,
249 child: Column(
250 crossAxisAlignment: CrossAxisAlignment.start,
251 children: <Widget>[
252 SizedBox(
253 height: 56,
254 child: Row(
255 children: <Widget>[
256 const SizedBox(width: 6),
257 InkWell(
258 key: const ValueKey<String>('ReplyLogo'),
259 borderRadius: const BorderRadius.all(Radius.circular(16)),
260 onTap: () {
261 extended.value = !extended.value;
262 },
263 child: Row(
264 children: <Widget>[
265 Transform.rotate(
266 angle: animation.value * math.pi,
267 child: const Icon(
268 Icons.arrow_left,
269 color: ReplyColors.white50,
270 size: 16,
271 ),
272 ),
273 const _ReplyLogo(),
274 const SizedBox(width: 10),
275 Align(
276 alignment: AlignmentDirectional.centerStart,
277 widthFactor: animation.value,
278 child: Opacity(
279 opacity: animation.value,
280 child: Text(
281 'REPLY',
282 style: textTheme.bodyLarge!.copyWith(color: ReplyColors.white50),
283 ),
284 ),
285 ),
286 SizedBox(width: 18 * animation.value),
287 ],
288 ),
289 ),
290 if (animation.value > 0)
291 Opacity(
292 opacity: animation.value,
293 child: const Row(
294 children: <Widget>[
295 SizedBox(width: 18),
296 ProfileAvatar(avatar: 'reply/avatars/avatar_2.jpg', radius: 16),
297 SizedBox(width: 12),
298 Icon(Icons.settings, color: ReplyColors.white50),
299 ],
300 ),
301 ),
302 ],
303 ),
304 ),
305 const SizedBox(height: 20),
306 Padding(
307 padding: const EdgeInsetsDirectional.only(start: 8),
308 child: _ReplyFab(extended: extended.value),
309 ),
310 const SizedBox(height: 8),
311 ],
312 ),
313 );
314 },
315 );
316 }
317}
318
319class _NavigationRailFolderSection extends StatelessWidget {
320 const _NavigationRailFolderSection({required this.folders});
321
322 final Map<String, String> folders;
323
324 @override
325 Widget build(BuildContext context) {
326 final ThemeData theme = Theme.of(context);
327 final TextTheme textTheme = theme.textTheme;
328 final NavigationRailThemeData navigationRailTheme = theme.navigationRailTheme;
329 final Animation<double> animation = NavigationRail.extendedAnimation(context);
330
331 return AnimatedBuilder(
332 animation: animation,
333 builder: (BuildContext context, Widget? child) {
334 return Visibility(
335 maintainAnimation: true,
336 maintainState: true,
337 visible: animation.value > 0,
338 child: Opacity(
339 opacity: animation.value,
340 child: Align(
341 widthFactor: animation.value,
342 alignment: AlignmentDirectional.centerStart,
343 child: SizedBox(
344 height: 485,
345 width: 256,
346 child: ListView(
347 padding: const EdgeInsets.all(12),
348 physics: const NeverScrollableScrollPhysics(),
349 children: <Widget>[
350 const Divider(
351 color: ReplyColors.blue200,
352 thickness: 0.4,
353 indent: 14,
354 endIndent: 16,
355 ),
356 const SizedBox(height: 16),
357 Padding(
358 padding: const EdgeInsetsDirectional.only(start: 16),
359 child: Text(
360 'FOLDERS',
361 style: textTheme.bodySmall!.copyWith(
362 color: navigationRailTheme.unselectedLabelTextStyle!.color,
363 ),
364 ),
365 ),
366 const SizedBox(height: 8),
367 for (final String folder in folders.keys)
368 InkWell(
369 borderRadius: const BorderRadius.all(Radius.circular(36)),
370 onTap: () {},
371 child: Column(
372 children: <Widget>[
373 Row(
374 children: <Widget>[
375 const SizedBox(width: 12),
376 ImageIcon(
377 AssetImage(folders[folder]!, package: _assetsPackage),
378 color: navigationRailTheme.unselectedLabelTextStyle!.color,
379 ),
380 const SizedBox(width: 24),
381 Text(
382 folder,
383 style: textTheme.bodyLarge!.copyWith(
384 color: navigationRailTheme.unselectedLabelTextStyle!.color,
385 ),
386 ),
387 const SizedBox(height: 72),
388 ],
389 ),
390 ],
391 ),
392 ),
393 ],
394 ),
395 ),
396 ),
397 ),
398 );
399 },
400 );
401 }
402}
403
404class _MobileNav extends StatefulWidget {
405 const _MobileNav({required this.destinations, required this.folders, required this.onItemTapped});
406
407 final List<_Destination> destinations;
408 final Map<String, String> folders;
409 final void Function(int, MailboxPageType) onItemTapped;
410
411 @override
412 _MobileNavState createState() => _MobileNavState();
413}
414
415class _MobileNavState extends State<_MobileNav> with TickerProviderStateMixin {
416 final GlobalKey<State<StatefulWidget>> _bottomDrawerKey = GlobalKey(debugLabel: 'Bottom Drawer');
417 late AnimationController _drawerController;
418 late AnimationController _dropArrowController;
419 late AnimationController _bottomAppBarController;
420 late Animation<double> _drawerCurve;
421 late Animation<double> _dropArrowCurve;
422 late Animation<double> _bottomAppBarCurve;
423
424 @override
425 void initState() {
426 super.initState();
427 _drawerController = AnimationController(duration: _kAnimationDuration, value: 0, vsync: this)
428 ..addListener(() {
429 if (_drawerController.value < 0.01) {
430 setState(() {
431 //Reload state when drawer is at its smallest to toggle visibility
432 //If state is reloaded before this drawer closes abruptly instead
433 //of animating.
434 });
435 }
436 });
437
438 _dropArrowController = AnimationController(duration: _kAnimationDuration, vsync: this);
439
440 _bottomAppBarController = AnimationController(
441 vsync: this,
442 value: 1,
443 duration: const Duration(milliseconds: 250),
444 );
445
446 _drawerCurve = CurvedAnimation(
447 parent: _drawerController,
448 curve: Easing.legacy,
449 reverseCurve: Easing.legacy.flipped,
450 );
451
452 _dropArrowCurve = CurvedAnimation(
453 parent: _dropArrowController,
454 curve: Easing.legacy,
455 reverseCurve: Easing.legacy.flipped,
456 );
457
458 _bottomAppBarCurve = CurvedAnimation(
459 parent: _bottomAppBarController,
460 curve: Easing.legacy,
461 reverseCurve: Easing.legacy.flipped,
462 );
463 }
464
465 @override
466 void dispose() {
467 _drawerController.dispose();
468 _dropArrowController.dispose();
469 _bottomAppBarController.dispose();
470 super.dispose();
471 }
472
473 bool get _bottomDrawerVisible => _drawerController.isForwardOrCompleted;
474
475 void _toggleBottomDrawerVisibility() {
476 if (_drawerController.value < 0.4) {
477 _drawerController.animateTo(0.4, curve: Easing.legacy);
478 _dropArrowController.animateTo(0.35, curve: Easing.legacy);
479 return;
480 }
481
482 _dropArrowController.forward();
483 _drawerController.fling(velocity: _bottomDrawerVisible ? -_kFlingVelocity : _kFlingVelocity);
484 }
485
486 double get _bottomDrawerHeight {
487 final RenderBox renderBox = _bottomDrawerKey.currentContext!.findRenderObject()! as RenderBox;
488 return renderBox.size.height;
489 }
490
491 void _handleDragUpdate(DragUpdateDetails details) {
492 _drawerController.value -= details.primaryDelta! / _bottomDrawerHeight;
493 }
494
495 void _handleDragEnd(DragEndDetails details) {
496 if (!_drawerController.isDismissed) {
497 return;
498 }
499
500 final double flingVelocity = details.velocity.pixelsPerSecond.dy / _bottomDrawerHeight;
501
502 if (flingVelocity < 0.0) {
503 _drawerController.fling(velocity: math.max(_kFlingVelocity, -flingVelocity));
504 } else if (flingVelocity > 0.0) {
505 _dropArrowController.forward();
506 _drawerController.fling(velocity: math.min(-_kFlingVelocity, -flingVelocity));
507 } else {
508 if (_drawerController.value < 0.6) {
509 _dropArrowController.forward();
510 }
511 _drawerController.fling(
512 velocity: _drawerController.value < 0.6 ? -_kFlingVelocity : _kFlingVelocity,
513 );
514 }
515 }
516
517 bool _handleScrollNotification(ScrollNotification notification) {
518 if (notification case UserScrollNotification(depth: 0)) {
519 switch (notification.direction) {
520 case ScrollDirection.forward:
521 _bottomAppBarController.forward();
522 case ScrollDirection.reverse:
523 _bottomAppBarController.reverse();
524 case ScrollDirection.idle:
525 break;
526 }
527 }
528 return false;
529 }
530
531 Widget _buildStack(BuildContext context, BoxConstraints constraints) {
532 final ui.Size drawerSize = constraints.biggest;
533 final double drawerTop = drawerSize.height;
534
535 final Animation<RelativeRect> drawerAnimation = RelativeRectTween(
536 begin: RelativeRect.fromLTRB(0.0, drawerTop, 0.0, 0.0),
537 end: RelativeRect.fill,
538 ).animate(_drawerCurve);
539
540 return Stack(
541 clipBehavior: Clip.none,
542 key: _bottomDrawerKey,
543 children: <Widget>[
544 NotificationListener<ScrollNotification>(
545 onNotification: _handleScrollNotification,
546 child: const _MailNavigator(child: MailboxBody()),
547 ),
548 MouseRegion(
549 cursor: SystemMouseCursors.click,
550 child: GestureDetector(
551 onTap: () {
552 _drawerController.reverse();
553 _dropArrowController.reverse();
554 },
555 child: Visibility(
556 maintainAnimation: true,
557 maintainState: true,
558 visible: _bottomDrawerVisible,
559 child: FadeTransition(
560 opacity: _drawerCurve,
561 child: Container(
562 height: MediaQuery.of(context).size.height,
563 width: MediaQuery.of(context).size.width,
564 color: Theme.of(context).bottomSheetTheme.modalBackgroundColor,
565 ),
566 ),
567 ),
568 ),
569 ),
570 PositionedTransition(
571 rect: drawerAnimation,
572 child: Visibility(
573 visible: _bottomDrawerVisible,
574 child: BottomDrawer(
575 onVerticalDragUpdate: _handleDragUpdate,
576 onVerticalDragEnd: _handleDragEnd,
577 leading: Consumer<EmailStore>(
578 builder: (BuildContext context, EmailStore model, Widget? child) {
579 return _BottomDrawerDestinations(
580 destinations: widget.destinations,
581 drawerController: _drawerController,
582 dropArrowController: _dropArrowController,
583 selectedMailbox: model.selectedMailboxPage,
584 onItemTapped: widget.onItemTapped,
585 );
586 },
587 ),
588 trailing: _BottomDrawerFolderSection(folders: widget.folders),
589 ),
590 ),
591 ),
592 ],
593 );
594 }
595
596 @override
597 Widget build(BuildContext context) {
598 return _SharedAxisTransitionSwitcher(
599 defaultChild: Scaffold(
600 extendBody: true,
601 body: LayoutBuilder(builder: _buildStack),
602 bottomNavigationBar: Consumer<EmailStore>(
603 builder: (BuildContext context, EmailStore model, Widget? child) {
604 return _AnimatedBottomAppBar(
605 bottomAppBarController: _bottomAppBarController,
606 bottomAppBarCurve: _bottomAppBarCurve,
607 bottomDrawerVisible: _bottomDrawerVisible,
608 drawerController: _drawerController,
609 dropArrowCurve: _dropArrowCurve,
610 navigationDestinations: widget.destinations,
611 selectedMailbox: model.selectedMailboxPage,
612 toggleBottomDrawerVisibility: _toggleBottomDrawerVisibility,
613 );
614 },
615 ),
616 floatingActionButton:
617 _bottomDrawerVisible
618 ? null
619 : const Padding(padding: EdgeInsetsDirectional.only(bottom: 8), child: _ReplyFab()),
620 floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
621 ),
622 );
623 }
624}
625
626class _AnimatedBottomAppBar extends StatelessWidget {
627 const _AnimatedBottomAppBar({
628 required this.bottomAppBarController,
629 required this.bottomAppBarCurve,
630 required this.bottomDrawerVisible,
631 required this.drawerController,
632 required this.dropArrowCurve,
633 required this.navigationDestinations,
634 this.selectedMailbox,
635 this.toggleBottomDrawerVisibility,
636 });
637
638 final AnimationController bottomAppBarController;
639 final Animation<double> bottomAppBarCurve;
640 final bool bottomDrawerVisible;
641 final AnimationController drawerController;
642 final Animation<double> dropArrowCurve;
643 final List<_Destination> navigationDestinations;
644 final MailboxPageType? selectedMailbox;
645 final ui.VoidCallback? toggleBottomDrawerVisibility;
646
647 @override
648 Widget build(BuildContext context) {
649 final Animation<double> fadeOut = Tween<double>(
650 begin: 1,
651 end: -1,
652 ).animate(drawerController.drive(CurveTween(curve: Easing.legacy)));
653
654 return Selector<EmailStore, bool>(
655 selector: (BuildContext context, EmailStore emailStore) => emailStore.onMailView,
656 builder: (BuildContext context, bool onMailView, Widget? child) {
657 bottomAppBarController.forward();
658
659 return SizeTransition(
660 sizeFactor: bottomAppBarCurve,
661 axisAlignment: -1,
662 child: Padding(
663 padding: const EdgeInsetsDirectional.only(top: 2),
664 child: BottomAppBar(
665 shape: const WaterfallNotchedRectangle(),
666 notchMargin: 6,
667 child: Container(
668 color: Colors.transparent,
669 height: kToolbarHeight,
670 child: Row(
671 mainAxisAlignment: MainAxisAlignment.spaceBetween,
672 children: <Widget>[
673 InkWell(
674 key: const ValueKey<String>('navigation_button'),
675 borderRadius: const BorderRadius.all(Radius.circular(16)),
676 onTap: toggleBottomDrawerVisibility,
677 child: Row(
678 children: <Widget>[
679 const SizedBox(width: 16),
680 RotationTransition(
681 turns: Tween<double>(begin: 0.0, end: 1.0).animate(dropArrowCurve),
682 child: const Icon(Icons.arrow_drop_up, color: ReplyColors.white50),
683 ),
684 const SizedBox(width: 8),
685 const _ReplyLogo(),
686 const SizedBox(width: 10),
687 _FadeThroughTransitionSwitcher(
688 fillColor: Colors.transparent,
689 child:
690 onMailView
691 ? const SizedBox(width: 48)
692 : FadeTransition(
693 opacity: fadeOut,
694 child: Text(
695 navigationDestinations.firstWhere((
696 _Destination destination,
697 ) {
698 return destination.type == selectedMailbox;
699 }).textLabel,
700 style: Theme.of(
701 context,
702 ).textTheme.bodyLarge!.copyWith(color: ReplyColors.white50),
703 ),
704 ),
705 ),
706 ],
707 ),
708 ),
709 Expanded(
710 child: ColoredBox(
711 color: Colors.transparent,
712 child: _BottomAppBarActionItems(drawerVisible: bottomDrawerVisible),
713 ),
714 ),
715 ],
716 ),
717 ),
718 ),
719 ),
720 );
721 },
722 );
723 }
724}
725
726class _BottomAppBarActionItems extends StatelessWidget {
727 const _BottomAppBarActionItems({required this.drawerVisible});
728
729 final bool drawerVisible;
730
731 @override
732 Widget build(BuildContext context) {
733 return Consumer<EmailStore>(
734 builder: (BuildContext context, EmailStore model, Widget? child) {
735 final bool onMailView = model.onMailView;
736 Color? starIconColor;
737
738 if (onMailView) {
739 starIconColor =
740 model.isCurrentEmailStarred
741 ? Theme.of(context).colorScheme.secondary
742 : ReplyColors.white50;
743 }
744
745 return _FadeThroughTransitionSwitcher(
746 fillColor: Colors.transparent,
747 child:
748 drawerVisible
749 ? Align(
750 key: UniqueKey(),
751 alignment: Alignment.centerRight,
752 child: IconButton(
753 icon: const Icon(Icons.settings),
754 color: ReplyColors.white50,
755 onPressed: () {},
756 ),
757 )
758 : onMailView
759 ? Row(
760 mainAxisAlignment: MainAxisAlignment.end,
761 children: <Widget>[
762 IconButton(
763 key: const ValueKey<String>('star_email_button'),
764 icon: ImageIcon(
765 const AssetImage(
766 '$_iconAssetLocation/twotone_star.png',
767 package: _assetsPackage,
768 ),
769 color: starIconColor,
770 ),
771 onPressed: () {
772 final Email currentEmail = model.currentEmail;
773 if (model.isCurrentEmailStarred) {
774 model.unstarEmail(currentEmail.id);
775 } else {
776 model.starEmail(currentEmail.id);
777 }
778 if (model.selectedMailboxPage == MailboxPageType.starred) {
779 mobileMailNavKey.currentState!.pop();
780 model.selectedEmailId = -1;
781 }
782 },
783 color: ReplyColors.white50,
784 ),
785 IconButton(
786 icon: const ImageIcon(
787 AssetImage(
788 '$_iconAssetLocation/twotone_delete.png',
789 package: _assetsPackage,
790 ),
791 ),
792 onPressed: () {
793 model.deleteEmail(model.selectedEmailId);
794
795 mobileMailNavKey.currentState!.pop();
796 model.selectedEmailId = -1;
797 },
798 color: ReplyColors.white50,
799 ),
800 IconButton(
801 icon: const Icon(Icons.more_vert),
802 onPressed: () {},
803 color: ReplyColors.white50,
804 ),
805 ],
806 )
807 : Align(
808 alignment: Alignment.centerRight,
809 child: IconButton(
810 key: const ValueKey<String>('ReplySearch'),
811 icon: const Icon(Icons.search),
812 color: ReplyColors.white50,
813 onPressed: () {
814 Provider.of<EmailStore>(context, listen: false).onSearchPage = true;
815 },
816 ),
817 ),
818 );
819 },
820 );
821 }
822}
823
824class _BottomDrawerDestinations extends StatelessWidget {
825 const _BottomDrawerDestinations({
826 required this.destinations,
827 required this.drawerController,
828 required this.dropArrowController,
829 required this.selectedMailbox,
830 required this.onItemTapped,
831 });
832
833 final List<_Destination> destinations;
834 final AnimationController drawerController;
835 final AnimationController dropArrowController;
836 final MailboxPageType selectedMailbox;
837 final void Function(int, MailboxPageType) onItemTapped;
838
839 @override
840 Widget build(BuildContext context) {
841 final ThemeData theme = Theme.of(context);
842 final List<Widget> destinationButtons = <Widget>[];
843
844 for (int index = 0; index < destinations.length; index += 1) {
845 final _Destination destination = destinations[index];
846 destinationButtons.add(
847 InkWell(
848 key: ValueKey<String>('Reply-${destination.textLabel}'),
849 onTap: () {
850 drawerController.reverse();
851 dropArrowController.forward();
852 Future<void>.delayed(
853 Duration(
854 milliseconds:
855 (drawerController.value == 1 ? 300 : 120) *
856 GalleryOptions.of(context).timeDilation.toInt(),
857 ),
858 () {
859 // Wait until animations are complete to reload the state.
860 // Delay scales with the timeDilation value of the gallery.
861 onItemTapped(index, destination.type);
862 },
863 );
864 },
865 child: ListTile(
866 mouseCursor: SystemMouseCursors.click,
867 leading: ImageIcon(
868 AssetImage(destination.icon, package: _assetsPackage),
869 color:
870 destination.type == selectedMailbox
871 ? theme.colorScheme.secondary
872 : theme.navigationRailTheme.unselectedLabelTextStyle!.color,
873 ),
874 title: Text(
875 destination.textLabel,
876 style: theme.textTheme.bodyMedium!.copyWith(
877 color:
878 destination.type == selectedMailbox
879 ? theme.colorScheme.secondary
880 : theme.navigationRailTheme.unselectedLabelTextStyle!.color,
881 ),
882 ),
883 ),
884 ),
885 );
886 }
887
888 return Column(children: destinationButtons);
889 }
890}
891
892class _Destination {
893 const _Destination({required this.type, required this.textLabel, required this.icon});
894
895 // Which mailbox page to display. For example, 'Starred' or 'Trash'.
896 final MailboxPageType type;
897
898 // The localized text label for the inbox.
899 final String textLabel;
900
901 // The icon that appears next to the text label for the inbox.
902 final String icon;
903}
904
905class _BottomDrawerFolderSection extends StatelessWidget {
906 const _BottomDrawerFolderSection({required this.folders});
907
908 final Map<String, String> folders;
909
910 @override
911 Widget build(BuildContext context) {
912 final ThemeData theme = Theme.of(context);
913 final NavigationRailThemeData navigationRailTheme = theme.navigationRailTheme;
914
915 return Column(
916 children: <Widget>[
917 for (final String folder in folders.keys)
918 InkWell(
919 onTap: () {},
920 child: ListTile(
921 mouseCursor: SystemMouseCursors.click,
922 leading: ImageIcon(
923 AssetImage(folders[folder]!, package: _assetsPackage),
924 color: navigationRailTheme.unselectedLabelTextStyle!.color,
925 ),
926 title: Text(
927 folder,
928 style: theme.textTheme.bodyMedium!.copyWith(
929 color: navigationRailTheme.unselectedLabelTextStyle!.color,
930 ),
931 ),
932 ),
933 ),
934 ],
935 );
936 }
937}
938
939class _MailNavigator extends StatefulWidget {
940 const _MailNavigator({required this.child});
941
942 final Widget child;
943
944 @override
945 _MailNavigatorState createState() => _MailNavigatorState();
946}
947
948class _MailNavigatorState extends State<_MailNavigator> {
949 static const String inboxRoute = '/reply/inbox';
950
951 @override
952 Widget build(BuildContext context) {
953 final bool isDesktop = isDisplayDesktop(context);
954
955 return Navigator(
956 restorationScopeId: 'replyMailNavigator',
957 key: isDesktop ? desktopMailNavKey : mobileMailNavKey,
958 initialRoute: inboxRoute,
959 onGenerateRoute: (RouteSettings settings) {
960 switch (settings.name) {
961 case inboxRoute:
962 return MaterialPageRoute<void>(
963 builder: (BuildContext context) {
964 return _FadeThroughTransitionSwitcher(
965 fillColor: Theme.of(context).scaffoldBackgroundColor,
966 child: widget.child,
967 );
968 },
969 settings: settings,
970 );
971 case ReplyApp.composeRoute:
972 return ReplyApp.createComposeRoute(settings);
973 }
974 return null;
975 },
976 );
977 }
978}
979
980class _ReplyLogo extends StatelessWidget {
981 const _ReplyLogo();
982
983 @override
984 Widget build(BuildContext context) {
985 return const ImageIcon(
986 AssetImage('reply/reply_logo.png', package: _assetsPackage),
987 size: 32,
988 color: ReplyColors.white50,
989 );
990 }
991}
992
993class _ReplyFab extends StatefulWidget {
994 const _ReplyFab({this.extended = false});
995
996 final bool extended;
997
998 @override
999 _ReplyFabState createState() => _ReplyFabState();
1000}
1001
1002class _ReplyFabState extends State<_ReplyFab> with SingleTickerProviderStateMixin {
1003 static final UniqueKey fabKey = UniqueKey();
1004 static const double _mobileFabDimension = 56;
1005
1006 void onPressed() {
1007 final bool onSearchPage = Provider.of<EmailStore>(context, listen: false).onSearchPage;
1008 // Navigator does not have an easy way to access the current
1009 // route when using a GlobalKey to keep track of NavigatorState.
1010 // We can use [Navigator.popUntil] in order to access the current
1011 // route, and check if it is a ComposePage. If it is not a
1012 // ComposePage and we are not on the SearchPage, then we can push
1013 // a ComposePage onto our navigator. We return true at the end
1014 // so nothing is popped.
1015 desktopMailNavKey.currentState!.popUntil((Route<void> route) {
1016 final String? currentRoute = route.settings.name;
1017 if (currentRoute != ReplyApp.composeRoute && !onSearchPage) {
1018 desktopMailNavKey.currentState!.restorablePushNamed(ReplyApp.composeRoute);
1019 }
1020 return true;
1021 });
1022 }
1023
1024 @override
1025 Widget build(BuildContext context) {
1026 final bool isDesktop = isDisplayDesktop(context);
1027 final ThemeData theme = Theme.of(context);
1028 const CircleBorder circleFabBorder = CircleBorder();
1029
1030 return Selector<EmailStore, bool>(
1031 selector: (BuildContext context, EmailStore emailStore) => emailStore.onMailView,
1032 builder: (BuildContext context, bool onMailView, Widget? child) {
1033 final _FadeThroughTransitionSwitcher fabSwitcher = _FadeThroughTransitionSwitcher(
1034 fillColor: Colors.transparent,
1035 child:
1036 onMailView
1037 ? Icon(Icons.reply_all, key: fabKey, color: Colors.black)
1038 : const Icon(Icons.create, color: Colors.black),
1039 );
1040 final String tooltip = onMailView ? 'Reply' : 'Compose';
1041
1042 if (isDesktop) {
1043 final Animation<double> animation = NavigationRail.extendedAnimation(context);
1044 return Container(
1045 height: 56,
1046 padding: EdgeInsets.symmetric(vertical: ui.lerpDouble(0, 6, animation.value)!),
1047 child:
1048 animation.value == 0
1049 ? FloatingActionButton(
1050 tooltip: tooltip,
1051 key: const ValueKey<String>('ReplyFab'),
1052 onPressed: onPressed,
1053 child: fabSwitcher,
1054 )
1055 : Align(
1056 alignment: AlignmentDirectional.centerStart,
1057 child: FloatingActionButton.extended(
1058 key: const ValueKey<String>('ReplyFab'),
1059 label: Row(
1060 children: <Widget>[
1061 fabSwitcher,
1062 SizedBox(width: 16 * animation.value),
1063 Align(
1064 alignment: AlignmentDirectional.centerStart,
1065 widthFactor: animation.value,
1066 child: Text(
1067 tooltip.toUpperCase(),
1068 style: Theme.of(context).textTheme.headlineSmall!.copyWith(
1069 fontSize: 16,
1070 color: theme.colorScheme.onSecondary,
1071 ),
1072 ),
1073 ),
1074 ],
1075 ),
1076 onPressed: onPressed,
1077 ),
1078 ),
1079 );
1080 } else {
1081 // TODO(x): State restoration of compose page on mobile is blocked because OpenContainer does not support restorablePush, https://github.com/flutter/gallery/issues/570.
1082 return OpenContainer(
1083 openBuilder: (BuildContext context, void Function() closedContainer) {
1084 return const ComposePage();
1085 },
1086 openColor: theme.cardColor,
1087 closedShape: circleFabBorder,
1088 closedColor: theme.colorScheme.secondary,
1089 closedElevation: 6,
1090 closedBuilder: (BuildContext context, void Function() openContainer) {
1091 return Tooltip(
1092 message: tooltip,
1093 child: InkWell(
1094 key: const ValueKey<String>('ReplyFab'),
1095 customBorder: circleFabBorder,
1096 onTap: openContainer,
1097 child: SizedBox(
1098 height: _mobileFabDimension,
1099 width: _mobileFabDimension,
1100 child: Center(child: fabSwitcher),
1101 ),
1102 ),
1103 );
1104 },
1105 );
1106 }
1107 },
1108 );
1109 }
1110}
1111
1112class _FadeThroughTransitionSwitcher extends StatelessWidget {
1113 const _FadeThroughTransitionSwitcher({required this.fillColor, required this.child});
1114
1115 final Widget child;
1116 final Color fillColor;
1117
1118 @override
1119 Widget build(BuildContext context) {
1120 return PageTransitionSwitcher(
1121 transitionBuilder: (
1122 Widget child,
1123 Animation<double> animation,
1124 Animation<double> secondaryAnimation,
1125 ) {
1126 return FadeThroughTransition(
1127 fillColor: fillColor,
1128 animation: animation,
1129 secondaryAnimation: secondaryAnimation,
1130 child: child,
1131 );
1132 },
1133 child: child,
1134 );
1135 }
1136}
1137
1138class _SharedAxisTransitionSwitcher extends StatelessWidget {
1139 const _SharedAxisTransitionSwitcher({required this.defaultChild});
1140
1141 final Widget defaultChild;
1142
1143 @override
1144 Widget build(BuildContext context) {
1145 return Selector<EmailStore, bool>(
1146 selector: (BuildContext context, EmailStore emailStore) => emailStore.onSearchPage,
1147 builder: (BuildContext context, bool onSearchPage, Widget? child) {
1148 return PageTransitionSwitcher(
1149 reverse: !onSearchPage,
1150 transitionBuilder: (
1151 Widget child,
1152 Animation<double> animation,
1153 Animation<double> secondaryAnimation,
1154 ) {
1155 return SharedAxisTransition(
1156 fillColor: Theme.of(context).colorScheme.background,
1157 animation: animation,
1158 secondaryAnimation: secondaryAnimation,
1159 transitionType: SharedAxisTransitionType.scaled,
1160 child: child,
1161 );
1162 },
1163 child: onSearchPage ? const SearchPage() : defaultChild,
1164 );
1165 },
1166 );
1167 }
1168}
1169

Provided by KDAB

Privacy Policy
Learn more about Flutter for embedded and desktop on industrialflutter.com