import 'package:wonders/common_libs.dart'; import 'package:wonders/logic/common/string_utils.dart'; import 'package:wonders/logic/data/wonder_data.dart'; import 'package:wonders/ui/common/app_icons.dart'; import 'package:wonders/ui/common/controls/app_page_indicator.dart'; import 'package:wonders/ui/common/gradient_container.dart'; import 'package:wonders/ui/common/themed_text.dart'; import 'package:wonders/ui/common/utils/app_haptics.dart'; import 'package:wonders/ui/screens/home_menu/home_menu.dart'; import 'package:wonders/ui/wonder_illustrations/common/animated_clouds.dart'; import 'package:wonders/ui/wonder_illustrations/common/wonder_illustration.dart'; import 'package:wonders/ui/wonder_illustrations/common/wonder_illustration_config.dart'; import 'package:wonders/ui/wonder_illustrations/common/wonder_title_text.dart'; part '_vertical_swipe_controller.dart'; part 'widgets/_animated_arrow_button.dart'; class HomeScreen extends StatefulWidget with GetItStatefulWidgetMixin { HomeScreen({Key? key}) : super(key: key); @override State createState() => _HomeScreenState(); } /// Shows a horizontally scrollable list PageView sandwiched between Foreground and Background layers /// arranged in a parallax style. class _HomeScreenState extends State with SingleTickerProviderStateMixin { late final _pageController = PageController( viewportFraction: 1, initialPage: _numWonders * 9999, // allow 'infinite' scrolling by starting at a very high page ); List get _wonders => wondersLogic.all; bool _isMenuOpen = false; /// Set initial wonderIndex late int _wonderIndex = 0; int get _numWonders => _wonders.length; /// Used to polish the transition when leaving this page for the details view. /// Used to capture the _swipeAmt at the time of transition, and freeze the wonder foreground in place as we transition away. double? _swipeOverride; /// Used to let the foreground fade in when this view is returned to (from details) bool _fadeInOnNextBuild = false; /// All of the items that should fade in when returning from details view. /// Using individual tweens is more efficient than tween the entire parent final _fadeAnims = []; WonderData get currentWonder => _wonders[_wonderIndex]; late final _VerticalSwipeController _swipeController = _VerticalSwipeController(this, _showDetailsPage); bool _isSelected(WonderType t) => t == currentWonder.type; void _handlePageViewChanged(v) { setState(() => _wonderIndex = v % _numWonders); AppHaptics.lightImpact(); } void _handleOpenMenuPressed() async { setState(() => _isMenuOpen = true); WonderType? pickedWonder = await appLogic.showFullscreenDialogRoute( context, HomeMenu(data: currentWonder), ); setState(() => _isMenuOpen = false); if (pickedWonder != null) { _setPageIndex(_wonders.indexWhere((w) => w.type == pickedWonder)); } } void _handleFadeAnimInit(AnimationController controller) { _fadeAnims.add(controller); controller.value = 1; } void _handlePageIndicatorDotPressed(int index) => _setPageIndex(index); void _setPageIndex(int index) { if (index == _wonderIndex) return; // To support infinite scrolling, we can't jump directly to the pressed index. Instead, make it relative to our current position. final pos = ((_pageController.page ?? 0) / _numWonders).floor() * _numWonders; _pageController.jumpToPage(pos + index); } void _showDetailsPage() async { _swipeOverride = _swipeController.swipeAmt.value; context.push(ScreenPaths.wonderDetails(currentWonder.type)); await Future.delayed(100.ms); _swipeOverride = null; _fadeInOnNextBuild = true; } void _startDelayedFgFade() async { try { for (var a in _fadeAnims) { a.value = 0; } await Future.delayed(300.ms); for (var a in _fadeAnims) { a.forward(); } } on Exception catch (e) { debugPrint(e.toString()); } } @override Widget build(BuildContext context) { if (_fadeInOnNextBuild == true) { _startDelayedFgFade(); _fadeInOnNextBuild = false; } return _swipeController.wrapGestureDetector(Container( color: $styles.colors.black, child: Stack( children: [ Stack( children: [ /// Background ..._buildBgAndClouds(), /// Wonders Illustrations (main content) _buildMgPageView(), /// Foreground illustrations and gradients _buildFgAndGradients(), /// Controls that float on top of the various illustrations _buildFloatingUi(), ], ).animate().fadeIn(), ], ), )); } Widget _buildMgPageView() { return ExcludeSemantics( child: PageView.builder( controller: _pageController, onPageChanged: _handlePageViewChanged, itemBuilder: (_, index) { final wonder = _wonders[index % _wonders.length]; final wonderType = wonder.type; bool isShowing = _isSelected(wonderType); return _swipeController.buildListener( builder: (swipeAmt, _, child) { final config = WonderIllustrationConfig.mg( isShowing: isShowing, zoom: .05 * swipeAmt, ); return WonderIllustration(wonderType, config: config); }, ); }, ), ); } List _buildBgAndClouds() { return [ // Background ..._wonders.map((e) { final config = WonderIllustrationConfig.bg(isShowing: _isSelected(e.type)); return WonderIllustration(e.type, config: config); }).toList(), // Clouds FractionallySizedBox( widthFactor: 1, heightFactor: .5, child: AnimatedClouds(wonderType: currentWonder.type, opacity: 1), ) ]; } Widget _buildFgAndGradients() { Widget buildSwipeableBgGradient(Color fgColor) { return _swipeController.buildListener(builder: (swipeAmt, isPointerDown, _) { return IgnorePointer( child: FractionallySizedBox( heightFactor: .6, child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ fgColor.withOpacity(0), fgColor.withOpacity(.5 + fgColor.opacity * .25 + (isPointerDown ? .05 : 0) + swipeAmt * .20), ], stops: const [0, 1], ), ), ), ), ); }); } final gradientColor = currentWonder.type.bgColor; return Stack(children: [ /// Foreground gradient-1, gets darker when swiping up BottomCenter( child: buildSwipeableBgGradient(gradientColor.withOpacity(.65)), ), /// Foreground decorators ..._wonders.map((e) { return _swipeController.buildListener(builder: (swipeAmt, _, child) { final config = WonderIllustrationConfig.fg( isShowing: _isSelected(e.type), zoom: .4 * (_swipeOverride ?? swipeAmt), ); return Animate( effects: const [FadeEffect()], onPlay: _handleFadeAnimInit, child: IgnorePointer(child: WonderIllustration(e.type, config: config))); }); }).toList(), /// Foreground gradient-2, gets darker when swiping up BottomCenter( child: buildSwipeableBgGradient(gradientColor), ), ]); } Widget _buildFloatingUi() { return Stack(children: [ /// Floating controls / UI AnimatedSwitcher( duration: $styles.times.fast, child: RepaintBoundary( child: OverflowBox( child: Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox(width: double.infinity), const Spacer(), /// Title Content LightText( child: IgnorePointer( ignoringSemantics: false, child: Transform.translate( offset: Offset(0, 30), child: Column( children: [ Semantics( liveRegion: true, button: true, header: true, onIncrease: () => _setPageIndex(_wonderIndex + 1), onDecrease: () => _setPageIndex(_wonderIndex - 1), onTap: () => _showDetailsPage(), // Hide the title when the menu is open for visual polish child: AnimatedOpacity( opacity: _isMenuOpen ? 0 : 1, duration: $styles.times.fast, child: WonderTitleText(currentWonder, enableShadows: true), ), ), Gap($styles.insets.md), Semantics( onIncrease: () => _setPageIndex(_wonderIndex + 1), onDecrease: () => _setPageIndex(_wonderIndex - 1), child: AppPageIndicator( count: _numWonders, controller: _pageController, color: $styles.colors.white, dotSize: 8, onDotPressed: _handlePageIndicatorDotPressed, semanticPageTitle: $strings.homeSemanticWonder, ), ), Gap($styles.insets.md), ], ), ), ), ), /// Animated arrow and background /// Wrap in a container that is full-width to make it easier to find for screen readers Container( width: double.infinity, alignment: Alignment.center, /// Lose state of child objects when index changes, this will re-run all the animated switcher and the arrow anim key: ValueKey(_wonderIndex), child: Stack( children: [ /// Expanding rounded rect that grows in height as user swipes up Positioned.fill( child: _swipeController.buildListener( builder: (swipeAmt, _, child) { double heightFactor = .5 + .5 * (1 + swipeAmt * 4); return FractionallySizedBox( alignment: Alignment.bottomCenter, heightFactor: heightFactor, child: Opacity(opacity: swipeAmt * .5, child: child), ); }, child: VtGradient( [$styles.colors.white.withOpacity(0), $styles.colors.white.withOpacity(1)], const [.3, 1], borderRadius: BorderRadius.circular(99), ), )), /// Arrow Btn that fades in and out _AnimatedArrowButton(onTap: _showDetailsPage, semanticTitle: currentWonder.title), ], ), ), Gap($styles.insets.md), ], ), ), ), ), /// Menu Btn TopLeft( child: AnimatedOpacity( duration: $styles.times.fast, opacity: _isMenuOpen ? 0 : 1, child: MergeSemantics( child: CircleIconBtn( icon: AppIcons.menu, onPressed: _handleOpenMenuPressed, semanticLabel: $strings.homeSemanticOpenMain, ).safe(), ), ), ), ]); } }