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 | |
5 | import 'dart:math' as math; |
6 | import 'dart:ui' as ui; |
7 | |
8 | import 'package:animations/animations.dart'; |
9 | import 'package:flutter/material.dart'; |
10 | import 'package:flutter/rendering.dart'; |
11 | import 'package:provider/provider.dart'; |
12 | |
13 | import '../../data/gallery_options.dart'; |
14 | import '../../gallery_localizations.dart'; |
15 | import '../../layout/adaptive.dart'; |
16 | import 'app.dart'; |
17 | import 'bottom_drawer.dart'; |
18 | import 'colors.dart'; |
19 | import 'compose_page.dart'; |
20 | import 'mailbox_body.dart'; |
21 | import 'model/email_model.dart'; |
22 | import 'model/email_store.dart'; |
23 | import 'profile_avatar.dart'; |
24 | import 'search_page.dart'; |
25 | import 'waterfall_notched_rectangle.dart'; |
26 | |
27 | const String _assetsPackage = 'flutter_gallery_assets'; |
28 | const String _iconAssetLocation = 'reply/icons'; |
29 | const String _folderIconAssetLocation = '$_iconAssetLocation /twotone_folder.png'; |
30 | final GlobalKey<NavigatorState> desktopMailNavKey = GlobalKey<NavigatorState>(); |
31 | final GlobalKey<NavigatorState> mobileMailNavKey = GlobalKey<NavigatorState>(); |
32 | const double _kFlingVelocity = 2.0; |
33 | const Duration _kAnimationDuration = Duration(milliseconds: 300); |
34 | |
35 | class AdaptiveNav extends StatefulWidget { |
36 | const AdaptiveNav({super.key}); |
37 | |
38 | @override |
39 | State<AdaptiveNav> createState() => _AdaptiveNavState(); |
40 | } |
41 | |
42 | class _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 | |
134 | class _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 | |
151 | class _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 | |
234 | class _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 | |
319 | class _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 | |
404 | class _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 | |
415 | class _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 | |
626 | class _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 | |
726 | class _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 | |
824 | class _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 | |
892 | class _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 | |
905 | class _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 | |
939 | class _MailNavigator extends StatefulWidget { |
940 | const _MailNavigator({required this.child}); |
941 | |
942 | final Widget child; |
943 | |
944 | @override |
945 | _MailNavigatorState createState() => _MailNavigatorState(); |
946 | } |
947 | |
948 | class _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 | |
980 | class _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 | |
993 | class _ReplyFab extends StatefulWidget { |
994 | const _ReplyFab({this.extended = false}); |
995 | |
996 | final bool extended; |
997 | |
998 | @override |
999 | _ReplyFabState createState() => _ReplyFabState(); |
1000 | } |
1001 | |
1002 | class _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 | |
1112 | class _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 | |
1138 | class _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 |
Definitions
- _assetsPackage
- _iconAssetLocation
- _folderIconAssetLocation
- desktopMailNavKey
- mobileMailNavKey
- _kFlingVelocity
- _kAnimationDuration
- AdaptiveNav
- AdaptiveNav
- createState
- _AdaptiveNavState
- initState
- build
- _onDestinationSelected
- _DesktopNav
- _DesktopNav
- createState
- _DesktopNavState
- initState
- build
- _NavigationRailHeader
- _NavigationRailHeader
- build
- _NavigationRailFolderSection
- _NavigationRailFolderSection
- build
- _MobileNav
- _MobileNav
- createState
- _MobileNavState
- initState
- dispose
- _bottomDrawerVisible
- _toggleBottomDrawerVisibility
- _bottomDrawerHeight
- _handleDragUpdate
- _handleDragEnd
- _handleScrollNotification
- _buildStack
- build
- _AnimatedBottomAppBar
- _AnimatedBottomAppBar
- build
- _BottomAppBarActionItems
- _BottomAppBarActionItems
- build
- _BottomDrawerDestinations
- _BottomDrawerDestinations
- build
- _Destination
- _Destination
- _BottomDrawerFolderSection
- _BottomDrawerFolderSection
- build
- _MailNavigator
- _MailNavigator
- createState
- _MailNavigatorState
- build
- _ReplyLogo
- _ReplyLogo
- build
- _ReplyFab
- _ReplyFab
- createState
- _ReplyFabState
- onPressed
- build
- _FadeThroughTransitionSwitcher
- _FadeThroughTransitionSwitcher
- build
- _SharedAxisTransitionSwitcher
- _SharedAxisTransitionSwitcher
Learn more about Flutter for embedded and desktop on industrialflutter.com