// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import '../../data/gallery_options.dart'; import '../../gallery_localizations.dart'; import '../../layout/adaptive.dart'; import '../../layout/image_placeholder.dart'; import 'backlayer.dart'; import 'border_tab_indicator.dart'; import 'colors.dart'; import 'header_form.dart'; import 'item_cards.dart'; import 'model/data.dart'; import 'model/destination.dart'; class _FrontLayer extends StatefulWidget { const _FrontLayer({ required this.title, required this.index, required this.mobileTopOffset, required this.restorationId, }); final String title; final int index; final double mobileTopOffset; final String restorationId; @override _FrontLayerState createState() => _FrontLayerState(); } class _FrontLayerState extends State<_FrontLayer> { List? destinations; static const double frontLayerBorderRadius = 16.0; static const EdgeInsets bottomPadding = EdgeInsets.only(bottom: 120); @override void didChangeDependencies() { super.didChangeDependencies(); // We use didChangeDependencies because the initialization involves an // InheritedWidget (for localization). However, we don't need to get // destinations again when, say, resizing the window. if (destinations == null) { if (widget.index == 0) { destinations = getFlyDestinations(context); } if (widget.index == 1) { destinations = getSleepDestinations(context); } if (widget.index == 2) { destinations = getEatDestinations(context); } } } Widget _header() { return Align( alignment: AlignmentDirectional.centerStart, child: Padding( padding: const EdgeInsets.only( top: 20, bottom: 22, ), child: SelectableText( widget.title, style: Theme.of(context).textTheme.titleSmall, ), ), ); } @override Widget build(BuildContext context) { final bool isDesktop = isDisplayDesktop(context); final bool isSmallDesktop = isDisplaySmallDesktop(context); final int crossAxisCount = isDesktop ? 4 : 1; return FocusTraversalGroup( policy: ReadingOrderTraversalPolicy(), child: Padding( padding: isDesktop ? EdgeInsets.zero : EdgeInsets.only(top: widget.mobileTopOffset), child: PhysicalShape( elevation: 16, color: cranePrimaryWhite, clipper: const ShapeBorderClipper( shape: RoundedRectangleBorder( borderRadius: BorderRadius.only( topLeft: Radius.circular(frontLayerBorderRadius), topRight: Radius.circular(frontLayerBorderRadius), ), ), ), child: Padding( padding: isDesktop ? EdgeInsets.symmetric( horizontal: isSmallDesktop ? appPaddingSmall : appPaddingLarge) .add(bottomPadding) : const EdgeInsets.symmetric(horizontal: 20).add(bottomPadding), child: Column( children: [ _header(), Expanded( child: MasonryGridView.count( key: ValueKey('CraneListView-${widget.index}'), restorationId: widget.restorationId, crossAxisCount: crossAxisCount, crossAxisSpacing: 16.0, itemBuilder: (BuildContext context, int index) => DestinationCard(destination: destinations![index]), itemCount: destinations!.length, ), ), ], ), ), ), ), ); } } /// Builds a Backdrop. /// /// A Backdrop widget has two layers, front and back. The front layer is shown /// by default, and slides down to show the back layer, from which a user /// can make a selection. The user can also configure the titles for when the /// front or back layer is showing. class Backdrop extends StatefulWidget { const Backdrop({ super.key, required this.frontLayer, required this.backLayerItems, required this.frontTitle, required this.backTitle, }); final Widget frontLayer; final List backLayerItems; final Widget frontTitle; final Widget backTitle; @override State createState() => _BackdropState(); } class _BackdropState extends State with TickerProviderStateMixin, RestorationMixin { final RestorableInt tabIndex = RestorableInt(0); late TabController _tabController; late Animation _flyLayerHorizontalOffset; late Animation _sleepLayerHorizontalOffset; late Animation _eatLayerHorizontalOffset; // How much the 'sleep' front layer is vertically offset relative to other // front layers, in pixels, with the mobile layout. static const double _sleepLayerTopOffset = 60.0; @override String get restorationId => 'tab_non_scrollable_demo'; @override void restoreState(RestorationBucket? oldBucket, bool initialRestore) { registerForRestoration(tabIndex, 'tab_index'); _tabController.index = tabIndex.value; } @override void initState() { super.initState(); _tabController = TabController(length: 3, vsync: this); _tabController.addListener(() { // When the tab controller's value is updated, make sure to update the // tab index value, which is state restorable. setState(() { tabIndex.value = _tabController.index; }); }); // Offsets to create a horizontal gap between front layers. final Animation tabControllerAnimation = _tabController.animation!; _flyLayerHorizontalOffset = tabControllerAnimation.drive( Tween(begin: Offset.zero, end: const Offset(-0.05, 0))); _sleepLayerHorizontalOffset = tabControllerAnimation.drive( Tween(begin: const Offset(0.05, 0), end: Offset.zero)); _eatLayerHorizontalOffset = tabControllerAnimation.drive(Tween( begin: const Offset(0.10, 0), end: const Offset(0.05, 0))); } @override void dispose() { _tabController.dispose(); tabIndex.dispose(); super.dispose(); } void _handleTabs(int tabIndex) { _tabController.animateTo(tabIndex, duration: const Duration(milliseconds: 300)); } @override Widget build(BuildContext context) { final bool isDesktop = isDisplayDesktop(context); final double textScaleFactor = GalleryOptions.of(context).textScaleFactor(context); final GalleryLocalizations localizations = GalleryLocalizations.of(context)!; return Material( color: cranePurple800, child: Padding( padding: const EdgeInsets.only(top: 12), child: FocusTraversalGroup( policy: ReadingOrderTraversalPolicy(), child: Scaffold( backgroundColor: cranePurple800, appBar: AppBar( automaticallyImplyLeading: false, systemOverlayStyle: SystemUiOverlayStyle.light, elevation: 0, titleSpacing: 0, flexibleSpace: CraneAppBar( tabController: _tabController, tabHandler: _handleTabs, ), ), body: Stack( children: [ BackLayer( tabController: _tabController, backLayerItems: widget.backLayerItems, ), Container( margin: EdgeInsets.only( top: isDesktop ? (isDisplaySmallDesktop(context) ? textFieldHeight * 3 : textFieldHeight * 2) + 20 * textScaleFactor / 2 : 175 + 140 * textScaleFactor / 2, ), // To display the middle front layer higher than the others, // we allow the TabBarView to overflow by an offset // (doubled because it technically overflows top & bottom). // The other front layers are top padded by this offset. child: LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) { return OverflowBox( maxHeight: constraints.maxHeight + _sleepLayerTopOffset * 2, child: TabBarView( physics: isDesktop ? const NeverScrollableScrollPhysics() : null, // use default TabBarView physics controller: _tabController, children: [ SlideTransition( position: _flyLayerHorizontalOffset, child: _FrontLayer( title: localizations.craneFlySubhead, index: 0, mobileTopOffset: _sleepLayerTopOffset, restorationId: 'fly-subhead', ), ), SlideTransition( position: _sleepLayerHorizontalOffset, child: _FrontLayer( title: localizations.craneSleepSubhead, index: 1, mobileTopOffset: 0, restorationId: 'sleep-subhead', ), ), SlideTransition( position: _eatLayerHorizontalOffset, child: _FrontLayer( title: localizations.craneEatSubhead, index: 2, mobileTopOffset: _sleepLayerTopOffset, restorationId: 'eat-subhead', ), ), ], ), ); }), ), ], ), ), ), ), ); } } class CraneAppBar extends StatefulWidget { const CraneAppBar({ super.key, this.tabHandler, required this.tabController, }); final void Function(int)? tabHandler; final TabController tabController; @override State createState() => _CraneAppBarState(); } class _CraneAppBarState extends State { @override Widget build(BuildContext context) { final bool isDesktop = isDisplayDesktop(context); final bool isSmallDesktop = isDisplaySmallDesktop(context); final double textScaleFactor = GalleryOptions.of(context).textScaleFactor(context); final GalleryLocalizations localizations = GalleryLocalizations.of(context)!; return SafeArea( child: Padding( padding: EdgeInsets.symmetric( horizontal: isDesktop && !isSmallDesktop ? appPaddingLarge : appPaddingSmall, ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ const ExcludeSemantics( child: FadeInImagePlaceholder( image: ResizeImage( AssetImage( 'crane/logo/logo.png', package: 'flutter_gallery_assets', ), width: 40, height: 60, ), placeholder: SizedBox( width: 40, height: 60, ), width: 40, height: 60, ), ), Expanded( child: Padding( padding: const EdgeInsetsDirectional.only(start: 24), child: Theme( data: Theme.of(context).copyWith( splashColor: Colors.transparent, ), child: TabBar( indicator: BorderTabIndicator( indicatorHeight: isDesktop ? 28 : 32, textScaleFactor: textScaleFactor, ), controller: widget.tabController, labelPadding: const EdgeInsets.symmetric(horizontal: 32), isScrollable: true, // left-align tabs on desktop labelStyle: Theme.of(context).textTheme.labelLarge, labelColor: cranePrimaryWhite, physics: const BouncingScrollPhysics(), unselectedLabelColor: cranePrimaryWhite.withOpacity(.6), onTap: (int index) => widget.tabController.animateTo( index, duration: const Duration(milliseconds: 300), ), tabs: [ Tab(text: localizations.craneFly), Tab(text: localizations.craneSleep), Tab(text: localizations.craneEat), ], ), ), ), ), ], ), ), ); } }