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:async';
6import 'dart:math' as math;
7
8import 'package:flutter/material.dart';
9import 'package:flutter/rendering.dart';
10import 'package:url_launcher/url_launcher.dart';
11
12import '../constants.dart';
13import '../data/demos.dart';
14import '../data/gallery_options.dart';
15import '../gallery_localizations.dart';
16import '../layout/adaptive.dart';
17import '../studies/crane/colors.dart';
18import '../studies/crane/routes.dart' as crane_routes;
19import '../studies/fortnightly/routes.dart' as fortnightly_routes;
20import '../studies/rally/colors.dart';
21import '../studies/rally/routes.dart' as rally_routes;
22import '../studies/reply/routes.dart' as reply_routes;
23import '../studies/shrine/colors.dart';
24import '../studies/shrine/routes.dart' as shrine_routes;
25import '../studies/starter/routes.dart' as starter_app_routes;
26import 'category_list_item.dart';
27import 'settings.dart';
28import 'splash.dart';
29
30const double _horizontalPadding = 32.0;
31const double _horizontalDesktopPadding = 81.0;
32const double _carouselHeightMin = 240.0;
33const double _carouselItemDesktopMargin = 8.0;
34const double _carouselItemMobileMargin = 4.0;
35const double _carouselItemWidth = 296.0;
36
37class ToggleSplashNotification extends Notification {}
38
39class HomePage extends StatelessWidget {
40 const HomePage({super.key});
41
42 @override
43 Widget build(BuildContext context) {
44 final bool isDesktop = isDisplayDesktop(context);
45 final GalleryLocalizations localizations = GalleryLocalizations.of(context)!;
46 final Map<String, GalleryDemo> studyDemos = Demos.studies(localizations);
47 final List<Widget> carouselCards = <Widget>[
48 _CarouselCard(
49 demo: studyDemos['reply'],
50 asset: const AssetImage('assets/studies/reply_card.png', package: 'flutter_gallery_assets'),
51 assetColor: const Color(0xFF344955),
52 assetDark: const AssetImage(
53 'assets/studies/reply_card_dark.png',
54 package: 'flutter_gallery_assets',
55 ),
56 assetDarkColor: const Color(0xFF1D2327),
57 textColor: Colors.white,
58 studyRoute: reply_routes.homeRoute,
59 ),
60 _CarouselCard(
61 demo: studyDemos['shrine'],
62 asset: const AssetImage(
63 'assets/studies/shrine_card.png',
64 package: 'flutter_gallery_assets',
65 ),
66 assetColor: const Color(0xFFFEDBD0),
67 assetDark: const AssetImage(
68 'assets/studies/shrine_card_dark.png',
69 package: 'flutter_gallery_assets',
70 ),
71 assetDarkColor: const Color(0xFF543B3C),
72 textColor: shrineBrown900,
73 studyRoute: shrine_routes.loginRoute,
74 ),
75 _CarouselCard(
76 demo: studyDemos['rally'],
77 textColor: RallyColors.accountColors[0],
78 asset: const AssetImage('assets/studies/rally_card.png', package: 'flutter_gallery_assets'),
79 assetColor: const Color(0xFFD1F2E6),
80 assetDark: const AssetImage(
81 'assets/studies/rally_card_dark.png',
82 package: 'flutter_gallery_assets',
83 ),
84 assetDarkColor: const Color(0xFF253538),
85 studyRoute: rally_routes.loginRoute,
86 ),
87 _CarouselCard(
88 demo: studyDemos['crane'],
89 asset: const AssetImage('assets/studies/crane_card.png', package: 'flutter_gallery_assets'),
90 assetColor: const Color(0xFFFBF6F8),
91 assetDark: const AssetImage(
92 'assets/studies/crane_card_dark.png',
93 package: 'flutter_gallery_assets',
94 ),
95 assetDarkColor: const Color(0xFF591946),
96 textColor: cranePurple700,
97 studyRoute: crane_routes.defaultRoute,
98 ),
99 _CarouselCard(
100 demo: studyDemos['fortnightly'],
101 asset: const AssetImage(
102 'assets/studies/fortnightly_card.png',
103 package: 'flutter_gallery_assets',
104 ),
105 assetColor: Colors.white,
106 assetDark: const AssetImage(
107 'assets/studies/fortnightly_card_dark.png',
108 package: 'flutter_gallery_assets',
109 ),
110 assetDarkColor: const Color(0xFF1F1F1F),
111 studyRoute: fortnightly_routes.defaultRoute,
112 ),
113 _CarouselCard(
114 demo: studyDemos['starterApp'],
115 asset: const AssetImage(
116 'assets/studies/starter_card.png',
117 package: 'flutter_gallery_assets',
118 ),
119 assetColor: const Color(0xFFFAF6FE),
120 assetDark: const AssetImage(
121 'assets/studies/starter_card_dark.png',
122 package: 'flutter_gallery_assets',
123 ),
124 assetDarkColor: const Color(0xFF3F3D45),
125 textColor: Colors.black,
126 studyRoute: starter_app_routes.defaultRoute,
127 ),
128 ];
129
130 if (isDesktop) {
131 // Desktop layout
132 final List<_DesktopCategoryItem> desktopCategoryItems = <_DesktopCategoryItem>[
133 _DesktopCategoryItem(
134 category: GalleryDemoCategory.material,
135 asset: const AssetImage(
136 'assets/icons/material/material.png',
137 package: 'flutter_gallery_assets',
138 ),
139 demos: Demos.materialDemos(localizations),
140 ),
141 _DesktopCategoryItem(
142 category: GalleryDemoCategory.cupertino,
143 asset: const AssetImage(
144 'assets/icons/cupertino/cupertino.png',
145 package: 'flutter_gallery_assets',
146 ),
147 demos: Demos.cupertinoDemos(localizations),
148 ),
149 _DesktopCategoryItem(
150 category: GalleryDemoCategory.other,
151 asset: const AssetImage(
152 'assets/icons/reference/reference.png',
153 package: 'flutter_gallery_assets',
154 ),
155 demos: Demos.otherDemos(localizations),
156 ),
157 ];
158
159 return Scaffold(
160 body: ListView(
161 // Makes integration tests possible.
162 key: const ValueKey<String>('HomeListView'),
163 primary: true,
164 padding: const EdgeInsetsDirectional.only(top: firstHeaderDesktopTopPadding),
165 children: <Widget>[
166 _DesktopHomeItem(child: _GalleryHeader()),
167 _DesktopCarousel(height: _carouselHeight(0.7, context), children: carouselCards),
168 _DesktopHomeItem(child: _CategoriesHeader()),
169 SizedBox(
170 height: 585,
171 child: _DesktopHomeItem(
172 child: Row(
173 mainAxisAlignment: MainAxisAlignment.spaceBetween,
174 children: spaceBetween(28, desktopCategoryItems),
175 ),
176 ),
177 ),
178 const SizedBox(height: 81),
179 _DesktopHomeItem(
180 child: Row(
181 children: <Widget>[
182 MouseRegion(
183 cursor: SystemMouseCursors.click,
184 child: GestureDetector(
185 onTap: () async {
186 final Uri url = Uri.parse('https://flutter.dev');
187 if (await canLaunchUrl(url)) {
188 await launchUrl(url);
189 }
190 },
191 excludeFromSemantics: true,
192 child: FadeInImage(
193 image:
194 Theme.of(context).colorScheme.brightness == Brightness.dark
195 ? const AssetImage(
196 'assets/logo/flutter_logo.png',
197 package: 'flutter_gallery_assets',
198 )
199 : const AssetImage(
200 'assets/logo/flutter_logo_color.png',
201 package: 'flutter_gallery_assets',
202 ),
203 placeholder: MemoryImage(kTransparentImage),
204 fadeInDuration: entranceAnimationDuration,
205 ),
206 ),
207 ),
208 const Expanded(
209 child: Wrap(
210 crossAxisAlignment: WrapCrossAlignment.center,
211 alignment: WrapAlignment.end,
212 children: <Widget>[
213 SettingsAbout(),
214 SettingsFeedback(),
215 SettingsAttribution(),
216 ],
217 ),
218 ),
219 ],
220 ),
221 ),
222 const SizedBox(height: 109),
223 ],
224 ),
225 );
226 } else {
227 // Mobile layout
228 return Scaffold(
229 body: _AnimatedHomePage(
230 restorationId: 'animated_page',
231 isSplashPageAnimationFinished: SplashPageAnimation.of(context)!.isFinished,
232 carouselCards: carouselCards,
233 ),
234 );
235 }
236 }
237
238 List<Widget> spaceBetween(double paddingBetween, List<Widget> children) {
239 return <Widget>[
240 for (int index = 0; index < children.length; index++) ...<Widget>[
241 Flexible(child: children[index]),
242 if (index < children.length - 1) SizedBox(width: paddingBetween),
243 ],
244 ];
245 }
246}
247
248class _GalleryHeader extends StatelessWidget {
249 @override
250 Widget build(BuildContext context) {
251 return Header(
252 color: Theme.of(context).colorScheme.primaryContainer,
253 text: GalleryLocalizations.of(context)!.homeHeaderGallery,
254 );
255 }
256}
257
258class _CategoriesHeader extends StatelessWidget {
259 @override
260 Widget build(BuildContext context) {
261 return Header(
262 color: Theme.of(context).colorScheme.primary,
263 text: GalleryLocalizations.of(context)!.homeHeaderCategories,
264 );
265 }
266}
267
268class Header extends StatelessWidget {
269 const Header({super.key, required this.color, required this.text});
270
271 final Color color;
272 final String text;
273
274 @override
275 Widget build(BuildContext context) {
276 return Align(
277 alignment: AlignmentDirectional.centerStart,
278 child: Padding(
279 padding: EdgeInsets.only(
280 top: isDisplayDesktop(context) ? 63 : 15,
281 bottom: isDisplayDesktop(context) ? 21 : 11,
282 ),
283 child: SelectableText(
284 text,
285 style: Theme.of(context).textTheme.headlineMedium!.apply(
286 color: color,
287 fontSizeDelta: isDisplayDesktop(context) ? desktopDisplay1FontDelta : 0,
288 ),
289 ),
290 ),
291 );
292 }
293}
294
295class _AnimatedHomePage extends StatefulWidget {
296 const _AnimatedHomePage({
297 required this.restorationId,
298 required this.carouselCards,
299 required this.isSplashPageAnimationFinished,
300 });
301
302 final String restorationId;
303 final List<Widget> carouselCards;
304 final bool isSplashPageAnimationFinished;
305
306 @override
307 _AnimatedHomePageState createState() => _AnimatedHomePageState();
308}
309
310class _AnimatedHomePageState extends State<_AnimatedHomePage>
311 with RestorationMixin, SingleTickerProviderStateMixin {
312 late AnimationController _animationController;
313 Timer? _launchTimer;
314 final RestorableBool _isMaterialListExpanded = RestorableBool(false);
315 final RestorableBool _isCupertinoListExpanded = RestorableBool(false);
316 final RestorableBool _isOtherListExpanded = RestorableBool(false);
317
318 @override
319 String get restorationId => widget.restorationId;
320
321 @override
322 void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
323 registerForRestoration(_isMaterialListExpanded, 'material_list');
324 registerForRestoration(_isCupertinoListExpanded, 'cupertino_list');
325 registerForRestoration(_isOtherListExpanded, 'other_list');
326 }
327
328 @override
329 void initState() {
330 super.initState();
331 _animationController = AnimationController(
332 vsync: this,
333 duration: const Duration(milliseconds: 800),
334 );
335
336 if (widget.isSplashPageAnimationFinished) {
337 // To avoid the animation from running when changing the window size from
338 // desktop to mobile, we do not animate our widget if the
339 // splash page animation is finished on initState.
340 _animationController.value = 1.0;
341 } else {
342 // Start our animation halfway through the splash page animation.
343 _launchTimer = Timer(halfSplashPageAnimationDuration, () {
344 _animationController.forward();
345 });
346 }
347 }
348
349 @override
350 void dispose() {
351 _animationController.dispose();
352 _launchTimer?.cancel();
353 _launchTimer = null;
354 _isMaterialListExpanded.dispose();
355 _isCupertinoListExpanded.dispose();
356 _isOtherListExpanded.dispose();
357 super.dispose();
358 }
359
360 @override
361 Widget build(BuildContext context) {
362 final GalleryLocalizations localizations = GalleryLocalizations.of(context)!;
363 final bool isTestMode = GalleryOptions.of(context).isTestMode;
364 return Stack(
365 children: <Widget>[
366 ListView(
367 // Makes integration tests possible.
368 key: const ValueKey<String>('HomeListView'),
369 primary: true,
370 restorationId: 'home_list_view',
371 children: <Widget>[
372 const SizedBox(height: 8),
373 Container(
374 margin: const EdgeInsets.symmetric(horizontal: _horizontalPadding),
375 child: _GalleryHeader(),
376 ),
377 _MobileCarousel(
378 animationController: _animationController,
379 restorationId: 'home_carousel',
380 children: widget.carouselCards,
381 ),
382 Container(
383 margin: const EdgeInsets.symmetric(horizontal: _horizontalPadding),
384 child: _CategoriesHeader(),
385 ),
386 _AnimatedCategoryItem(
387 startDelayFraction: 0.00,
388 controller: _animationController,
389 child: CategoryListItem(
390 key: const PageStorageKey<GalleryDemoCategory>(GalleryDemoCategory.material),
391 restorationId: 'home_material_category_list',
392 category: GalleryDemoCategory.material,
393 imageString: 'assets/icons/material/material.png',
394 demos: Demos.materialDemos(localizations),
395 initiallyExpanded: _isMaterialListExpanded.value || isTestMode,
396 onTap: (bool shouldOpenList) {
397 _isMaterialListExpanded.value = shouldOpenList;
398 },
399 ),
400 ),
401 _AnimatedCategoryItem(
402 startDelayFraction: 0.05,
403 controller: _animationController,
404 child: CategoryListItem(
405 key: const PageStorageKey<GalleryDemoCategory>(GalleryDemoCategory.cupertino),
406 restorationId: 'home_cupertino_category_list',
407 category: GalleryDemoCategory.cupertino,
408 imageString: 'assets/icons/cupertino/cupertino.png',
409 demos: Demos.cupertinoDemos(localizations),
410 initiallyExpanded: _isCupertinoListExpanded.value || isTestMode,
411 onTap: (bool shouldOpenList) {
412 _isCupertinoListExpanded.value = shouldOpenList;
413 },
414 ),
415 ),
416 _AnimatedCategoryItem(
417 startDelayFraction: 0.10,
418 controller: _animationController,
419 child: CategoryListItem(
420 key: const PageStorageKey<GalleryDemoCategory>(GalleryDemoCategory.other),
421 restorationId: 'home_other_category_list',
422 category: GalleryDemoCategory.other,
423 imageString: 'assets/icons/reference/reference.png',
424 demos: Demos.otherDemos(localizations),
425 initiallyExpanded: _isOtherListExpanded.value || isTestMode,
426 onTap: (bool shouldOpenList) {
427 _isOtherListExpanded.value = shouldOpenList;
428 },
429 ),
430 ),
431 ],
432 ),
433 Align(
434 alignment: Alignment.topCenter,
435 child: GestureDetector(
436 onVerticalDragEnd: (DragEndDetails details) {
437 if (details.velocity.pixelsPerSecond.dy > 200) {
438 ToggleSplashNotification().dispatch(context);
439 }
440 },
441 child: SafeArea(
442 child: Container(
443 height: 40,
444 // If we don't set the color, gestures are not detected.
445 color: Colors.transparent,
446 ),
447 ),
448 ),
449 ),
450 ],
451 );
452 }
453}
454
455class _DesktopHomeItem extends StatelessWidget {
456 const _DesktopHomeItem({required this.child});
457
458 final Widget child;
459
460 @override
461 Widget build(BuildContext context) {
462 return Align(
463 child: Container(
464 constraints: const BoxConstraints(maxWidth: maxHomeItemWidth),
465 padding: const EdgeInsets.symmetric(horizontal: _horizontalDesktopPadding),
466 child: child,
467 ),
468 );
469 }
470}
471
472class _DesktopCategoryItem extends StatelessWidget {
473 const _DesktopCategoryItem({required this.category, required this.asset, required this.demos});
474
475 final GalleryDemoCategory category;
476 final ImageProvider asset;
477 final List<GalleryDemo> demos;
478
479 @override
480 Widget build(BuildContext context) {
481 final ColorScheme colorScheme = Theme.of(context).colorScheme;
482 return Material(
483 borderRadius: BorderRadius.circular(10),
484 clipBehavior: Clip.antiAlias,
485 color: colorScheme.surface,
486 child: Semantics(
487 container: true,
488 child: FocusTraversalGroup(
489 policy: WidgetOrderTraversalPolicy(),
490 child: Column(
491 children: <Widget>[
492 _DesktopCategoryHeader(category: category, asset: asset),
493 Divider(height: 2, thickness: 2, color: colorScheme.background),
494 Flexible(
495 child: ListView.builder(
496 // Makes integration tests possible.
497 key: ValueKey<String>('${category.name}DemoList'),
498 primary: false,
499 itemBuilder:
500 (BuildContext context, int index) => CategoryDemoItem(demo: demos[index]),
501 itemCount: demos.length,
502 ),
503 ),
504 ],
505 ),
506 ),
507 ),
508 );
509 }
510}
511
512class _DesktopCategoryHeader extends StatelessWidget {
513 const _DesktopCategoryHeader({required this.category, required this.asset});
514
515 final GalleryDemoCategory category;
516 final ImageProvider asset;
517
518 @override
519 Widget build(BuildContext context) {
520 final ColorScheme colorScheme = Theme.of(context).colorScheme;
521 return Material(
522 // Makes integration tests possible.
523 key: ValueKey<String>('${category.name}CategoryHeader'),
524 color: colorScheme.onBackground,
525 child: Row(
526 children: <Widget>[
527 Padding(
528 padding: const EdgeInsets.all(10),
529 child: FadeInImage(
530 image: asset,
531 placeholder: MemoryImage(kTransparentImage),
532 fadeInDuration: entranceAnimationDuration,
533 width: 64,
534 height: 64,
535 excludeFromSemantics: true,
536 ),
537 ),
538 Flexible(
539 child: Padding(
540 padding: const EdgeInsetsDirectional.only(start: 8),
541 child: Semantics(
542 header: true,
543 child: SelectableText(
544 category.displayTitle(GalleryLocalizations.of(context)!)!,
545 style: Theme.of(
546 context,
547 ).textTheme.headlineSmall!.apply(color: colorScheme.onSurface),
548 ),
549 ),
550 ),
551 ),
552 ],
553 ),
554 );
555 }
556}
557
558/// Animates the category item to stagger in. The [_AnimatedCategoryItem.startDelayFraction]
559/// gives a delay in the unit of a fraction of the whole animation duration,
560/// which is defined in [_AnimatedHomePageState].
561class _AnimatedCategoryItem extends StatelessWidget {
562 _AnimatedCategoryItem({
563 required double startDelayFraction,
564 required this.controller,
565 required this.child,
566 }) : topPaddingAnimation = Tween<double>(begin: 60.0, end: 0.0).animate(
567 CurvedAnimation(
568 parent: controller,
569 curve: Interval(
570 0.000 + startDelayFraction,
571 0.400 + startDelayFraction,
572 curve: Curves.ease,
573 ),
574 ),
575 );
576
577 final Widget child;
578 final AnimationController controller;
579 final Animation<double> topPaddingAnimation;
580
581 @override
582 Widget build(BuildContext context) {
583 return AnimatedBuilder(
584 animation: controller,
585 builder: (BuildContext context, Widget? child) {
586 return Padding(padding: EdgeInsets.only(top: topPaddingAnimation.value), child: child);
587 },
588 child: child,
589 );
590 }
591}
592
593/// Animates the carousel to come in from the right.
594class _AnimatedCarousel extends StatelessWidget {
595 _AnimatedCarousel({required this.child, required this.controller})
596 : startPositionAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
597 CurvedAnimation(
598 parent: controller,
599 curve: const Interval(0.200, 0.800, curve: Curves.ease),
600 ),
601 );
602
603 final Widget child;
604 final AnimationController controller;
605 final Animation<double> startPositionAnimation;
606
607 @override
608 Widget build(BuildContext context) {
609 return LayoutBuilder(
610 builder: (BuildContext context, BoxConstraints constraints) {
611 return Stack(
612 children: <Widget>[
613 SizedBox(height: _carouselHeight(.4, context)),
614 AnimatedBuilder(
615 animation: controller,
616 builder: (BuildContext context, Widget? child) {
617 return PositionedDirectional(
618 start: constraints.maxWidth * startPositionAnimation.value,
619 child: child!,
620 );
621 },
622 child: SizedBox(
623 height: _carouselHeight(.4, context),
624 width: constraints.maxWidth,
625 child: child,
626 ),
627 ),
628 ],
629 );
630 },
631 );
632 }
633}
634
635/// Animates a carousel card to come in from the right.
636class _AnimatedCarouselCard extends StatelessWidget {
637 _AnimatedCarouselCard({required this.child, required this.controller})
638 : startPaddingAnimation = Tween<double>(begin: _horizontalPadding, end: 0.0).animate(
639 CurvedAnimation(
640 parent: controller,
641 curve: const Interval(0.900, 1.000, curve: Curves.ease),
642 ),
643 );
644
645 final Widget child;
646 final AnimationController controller;
647 final Animation<double> startPaddingAnimation;
648
649 @override
650 Widget build(BuildContext context) {
651 return AnimatedBuilder(
652 animation: controller,
653 builder: (BuildContext context, Widget? child) {
654 return Padding(
655 padding: EdgeInsetsDirectional.only(start: startPaddingAnimation.value),
656 child: child,
657 );
658 },
659 child: child,
660 );
661 }
662}
663
664class _MobileCarousel extends StatefulWidget {
665 const _MobileCarousel({
666 required this.animationController,
667 this.restorationId,
668 required this.children,
669 });
670
671 final AnimationController animationController;
672 final String? restorationId;
673 final List<Widget> children;
674
675 @override
676 _MobileCarouselState createState() => _MobileCarouselState();
677}
678
679class _MobileCarouselState extends State<_MobileCarousel>
680 with RestorationMixin, SingleTickerProviderStateMixin {
681 late PageController _controller;
682
683 final RestorableInt _currentPage = RestorableInt(0);
684
685 @override
686 String? get restorationId => widget.restorationId;
687
688 @override
689 void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
690 registerForRestoration(_currentPage, 'carousel_page');
691 }
692
693 @override
694 void didChangeDependencies() {
695 super.didChangeDependencies();
696 // The viewPortFraction is calculated as the width of the device minus the
697 // padding.
698 final double width = MediaQuery.of(context).size.width;
699 const double padding = _carouselItemMobileMargin * 2;
700 _controller = PageController(
701 initialPage: _currentPage.value,
702 viewportFraction: (_carouselItemWidth + padding) / width,
703 );
704 }
705
706 @override
707 void dispose() {
708 _controller.dispose();
709 _currentPage.dispose();
710 super.dispose();
711 }
712
713 Widget builder(int index) {
714 final AnimatedBuilder carouselCard = AnimatedBuilder(
715 animation: _controller,
716 builder: (BuildContext context, Widget? child) {
717 double value;
718 if (_controller.position.haveDimensions) {
719 value = _controller.page! - index;
720 } else {
721 // If haveDimensions is false, use _currentPage to calculate value.
722 value = (_currentPage.value - index).toDouble();
723 }
724 // .3 is an approximation of the curve used in the design.
725 value = (1 - (value.abs() * .3)).clamp(0, 1).toDouble();
726 value = Curves.easeOut.transform(value);
727
728 return Transform.scale(scale: value, child: child);
729 },
730 child: widget.children[index],
731 );
732
733 // We only want the second card to be animated.
734 if (index == 1) {
735 return _AnimatedCarouselCard(controller: widget.animationController, child: carouselCard);
736 } else {
737 return carouselCard;
738 }
739 }
740
741 @override
742 Widget build(BuildContext context) {
743 return _AnimatedCarousel(
744 controller: widget.animationController,
745 child: PageView.builder(
746 // Makes integration tests possible.
747 key: const ValueKey<String>('studyDemoList'),
748 onPageChanged: (int value) {
749 setState(() {
750 _currentPage.value = value;
751 });
752 },
753 controller: _controller,
754 pageSnapping: false,
755 itemCount: widget.children.length,
756 itemBuilder: (BuildContext context, int index) => builder(index),
757 allowImplicitScrolling: true,
758 ),
759 );
760 }
761}
762
763/// This creates a horizontally scrolling [ListView] of items.
764///
765/// This class uses a [ListView] with a custom [ScrollPhysics] to enable
766/// snapping behavior. A [PageView] was considered but does not allow for
767/// multiple pages visible without centering the first page.
768class _DesktopCarousel extends StatefulWidget {
769 const _DesktopCarousel({required this.height, required this.children});
770
771 final double height;
772 final List<Widget> children;
773
774 @override
775 _DesktopCarouselState createState() => _DesktopCarouselState();
776}
777
778class _DesktopCarouselState extends State<_DesktopCarousel> {
779 late ScrollController _controller;
780
781 @override
782 void initState() {
783 super.initState();
784 _controller = ScrollController();
785 _controller.addListener(() {
786 setState(() {});
787 });
788 }
789
790 @override
791 void dispose() {
792 _controller.dispose();
793 super.dispose();
794 }
795
796 @override
797 Widget build(BuildContext context) {
798 bool showPreviousButton = false;
799 bool showNextButton = true;
800 // Only check this after the _controller has been attached to the ListView.
801 if (_controller.hasClients) {
802 showPreviousButton = _controller.offset > 0;
803 showNextButton = _controller.offset < _controller.position.maxScrollExtent;
804 }
805
806 final bool isDesktop = isDisplayDesktop(context);
807
808 return Align(
809 child: Container(
810 height: widget.height,
811 constraints: const BoxConstraints(maxWidth: maxHomeItemWidth),
812 child: Stack(
813 children: <Widget>[
814 ListView.builder(
815 padding: EdgeInsets.symmetric(
816 horizontal:
817 isDesktop
818 ? _horizontalDesktopPadding - _carouselItemDesktopMargin
819 : _horizontalPadding - _carouselItemMobileMargin,
820 ),
821 scrollDirection: Axis.horizontal,
822 primary: false,
823 physics: const _SnappingScrollPhysics(),
824 controller: _controller,
825 itemExtent: _carouselItemWidth,
826 itemCount: widget.children.length,
827 itemBuilder:
828 (BuildContext context, int index) => Padding(
829 padding: const EdgeInsets.symmetric(vertical: 8.0),
830 child: widget.children[index],
831 ),
832 ),
833 if (showPreviousButton)
834 _DesktopPageButton(
835 onTap: () {
836 _controller.animateTo(
837 _controller.offset - _carouselItemWidth,
838 duration: const Duration(milliseconds: 200),
839 curve: Curves.easeInOut,
840 );
841 },
842 ),
843 if (showNextButton)
844 _DesktopPageButton(
845 isEnd: true,
846 onTap: () {
847 _controller.animateTo(
848 _controller.offset + _carouselItemWidth,
849 duration: const Duration(milliseconds: 200),
850 curve: Curves.easeInOut,
851 );
852 },
853 ),
854 ],
855 ),
856 ),
857 );
858 }
859}
860
861/// Scrolling physics that snaps to the new item in the [_DesktopCarousel].
862class _SnappingScrollPhysics extends ScrollPhysics {
863 const _SnappingScrollPhysics({super.parent});
864
865 @override
866 _SnappingScrollPhysics applyTo(ScrollPhysics? ancestor) {
867 return _SnappingScrollPhysics(parent: buildParent(ancestor));
868 }
869
870 double _getTargetPixels(ScrollMetrics position, Tolerance tolerance, double velocity) {
871 final double itemWidth = position.viewportDimension / 4;
872 double item = position.pixels / itemWidth;
873 if (velocity < -tolerance.velocity) {
874 item -= 0.5;
875 } else if (velocity > tolerance.velocity) {
876 item += 0.5;
877 }
878 return math.min(item.roundToDouble() * itemWidth, position.maxScrollExtent);
879 }
880
881 @override
882 Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) {
883 if ((velocity <= 0.0 && position.pixels <= position.minScrollExtent) ||
884 (velocity >= 0.0 && position.pixels >= position.maxScrollExtent)) {
885 return super.createBallisticSimulation(position, velocity);
886 }
887 final Tolerance tolerance = toleranceFor(position);
888 final double target = _getTargetPixels(position, tolerance, velocity);
889 if (target != position.pixels) {
890 return ScrollSpringSimulation(
891 spring,
892 position.pixels,
893 target,
894 velocity,
895 tolerance: tolerance,
896 );
897 }
898 return null;
899 }
900
901 @override
902 bool get allowImplicitScrolling => true;
903}
904
905class _DesktopPageButton extends StatelessWidget {
906 const _DesktopPageButton({this.isEnd = false, this.onTap});
907
908 final bool isEnd;
909 final GestureTapCallback? onTap;
910
911 @override
912 Widget build(BuildContext context) {
913 const double buttonSize = 58.0;
914 const double padding = _horizontalDesktopPadding - buttonSize / 2;
915 return ExcludeSemantics(
916 child: Align(
917 alignment: isEnd ? AlignmentDirectional.centerEnd : AlignmentDirectional.centerStart,
918 child: Container(
919 width: buttonSize,
920 height: buttonSize,
921 margin: EdgeInsetsDirectional.only(start: isEnd ? 0 : padding, end: isEnd ? padding : 0),
922 child: Tooltip(
923 message:
924 isEnd
925 ? MaterialLocalizations.of(context).nextPageTooltip
926 : MaterialLocalizations.of(context).previousPageTooltip,
927 child: Material(
928 color: Colors.black.withOpacity(0.5),
929 shape: const CircleBorder(),
930 clipBehavior: Clip.antiAlias,
931 child: InkWell(
932 onTap: onTap,
933 child: Icon(
934 isEnd ? Icons.arrow_forward_ios : Icons.arrow_back_ios,
935 color: Colors.white,
936 ),
937 ),
938 ),
939 ),
940 ),
941 ),
942 );
943 }
944}
945
946class _CarouselCard extends StatelessWidget {
947 const _CarouselCard({
948 required this.demo,
949 this.asset,
950 this.assetDark,
951 this.assetColor,
952 this.assetDarkColor,
953 this.textColor,
954 required this.studyRoute,
955 });
956
957 final GalleryDemo? demo;
958 final ImageProvider? asset;
959 final ImageProvider? assetDark;
960 final Color? assetColor;
961 final Color? assetDarkColor;
962 final Color? textColor;
963 final String studyRoute;
964
965 @override
966 Widget build(BuildContext context) {
967 final TextTheme textTheme = Theme.of(context).textTheme;
968 final bool isDark = Theme.of(context).colorScheme.brightness == Brightness.dark;
969 final ImageProvider<Object>? asset = isDark ? assetDark : this.asset;
970 final Color? assetColor = isDark ? assetDarkColor : this.assetColor;
971 final Color? textColor = isDark ? Colors.white.withOpacity(0.87) : this.textColor;
972 final bool isDesktop = isDisplayDesktop(context);
973
974 return Container(
975 padding: EdgeInsets.symmetric(
976 horizontal: isDesktop ? _carouselItemDesktopMargin : _carouselItemMobileMargin,
977 ),
978 margin: const EdgeInsets.symmetric(vertical: 16.0),
979 height: _carouselHeight(0.7, context),
980 width: _carouselItemWidth,
981 child: Material(
982 // Makes integration tests possible.
983 key: ValueKey<String>(demo!.describe),
984 color: assetColor,
985 elevation: 4,
986 shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
987 clipBehavior: Clip.antiAlias,
988 child: Stack(
989 fit: StackFit.expand,
990 children: <Widget>[
991 if (asset != null)
992 FadeInImage(
993 image: asset,
994 placeholder: MemoryImage(kTransparentImage),
995 fit: BoxFit.cover,
996 height: _carouselHeightMin,
997 fadeInDuration: entranceAnimationDuration,
998 ),
999 Padding(
1000 padding: const EdgeInsetsDirectional.fromSTEB(16, 0, 16, 16),
1001 child: Column(
1002 crossAxisAlignment: CrossAxisAlignment.start,
1003 mainAxisAlignment: MainAxisAlignment.end,
1004 children: <Widget>[
1005 Text(
1006 demo!.title,
1007 style: textTheme.bodySmall!.apply(color: textColor),
1008 maxLines: 3,
1009 overflow: TextOverflow.visible,
1010 ),
1011 Text(
1012 demo!.subtitle,
1013 style: textTheme.labelSmall!.apply(color: textColor),
1014 maxLines: 5,
1015 overflow: TextOverflow.visible,
1016 ),
1017 ],
1018 ),
1019 ),
1020 Positioned.fill(
1021 child: Material(
1022 color: Colors.transparent,
1023 child: InkWell(
1024 onTap: () {
1025 Navigator.of(
1026 context,
1027 ).popUntil((Route<void> route) => route.settings.name == '/');
1028 Navigator.of(context).restorablePushNamed(studyRoute);
1029 },
1030 ),
1031 ),
1032 ),
1033 ],
1034 ),
1035 ),
1036 );
1037 }
1038}
1039
1040double _carouselHeight(double scaleFactor, BuildContext context) => math.max(
1041 _carouselHeightMin * GalleryOptions.of(context).textScaleFactor(context) * scaleFactor,
1042 _carouselHeightMin,
1043);
1044
1045/// Wrap the studies with this to display a back button and allow the user to
1046/// exit them at any time.
1047class StudyWrapper extends StatefulWidget {
1048 const StudyWrapper({
1049 super.key,
1050 required this.study,
1051 this.alignment = AlignmentDirectional.bottomStart,
1052 this.hasBottomNavBar = false,
1053 });
1054
1055 final Widget study;
1056 final bool hasBottomNavBar;
1057 final AlignmentDirectional alignment;
1058
1059 @override
1060 State<StudyWrapper> createState() => _StudyWrapperState();
1061}
1062
1063class _StudyWrapperState extends State<StudyWrapper> {
1064 @override
1065 Widget build(BuildContext context) {
1066 final ColorScheme colorScheme = Theme.of(context).colorScheme;
1067 final TextTheme textTheme = Theme.of(context).textTheme;
1068 return ApplyTextOptions(
1069 child: Stack(
1070 children: <Widget>[
1071 Semantics(
1072 sortKey: const OrdinalSortKey(1),
1073 child: RestorationScope(restorationId: 'study_wrapper', child: widget.study),
1074 ),
1075 SafeArea(
1076 child: Align(
1077 alignment: widget.alignment,
1078 child: Padding(
1079 padding: EdgeInsets.symmetric(
1080 horizontal: 16.0,
1081 vertical: widget.hasBottomNavBar ? kBottomNavigationBarHeight + 16.0 : 16.0,
1082 ),
1083 child: Semantics(
1084 sortKey: const OrdinalSortKey(0),
1085 label: GalleryLocalizations.of(context)!.backToGallery,
1086 button: true,
1087 enabled: true,
1088 excludeSemantics: true,
1089 child: FloatingActionButton.extended(
1090 heroTag: _BackButtonHeroTag(),
1091 key: const ValueKey<String>('Back'),
1092 onPressed: () {
1093 Navigator.of(
1094 context,
1095 ).popUntil((Route<void> route) => route.settings.name == '/');
1096 },
1097 icon: IconTheme(
1098 data: IconThemeData(color: colorScheme.onPrimary),
1099 child: const BackButtonIcon(),
1100 ),
1101 label: Text(
1102 MaterialLocalizations.of(context).backButtonTooltip,
1103 style: textTheme.labelLarge!.apply(color: colorScheme.onPrimary),
1104 ),
1105 ),
1106 ),
1107 ),
1108 ),
1109 ),
1110 ],
1111 ),
1112 );
1113 }
1114}
1115
1116class _BackButtonHeroTag {}
1117

Provided by KDAB

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