import 'dart:async'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:stacked/stacked.dart'; import '../../about/internal/application/MeDescription.dart'; import '../../about/internal/application/NavigatorType.dart'; import '../../about/internal/application/RoutineKind.dart'; import '../../about/internal/application/TextType.dart'; import '../../configs/Navigator.dart'; import '../../designs/AmbientBackground.dart'; import '../../designs/GlassSurface.dart'; import '../../designs/GroundedMark.dart'; import '../../designs/Responsive.dart'; import '../../designs/scene/SceneArt.dart'; import '../../designs/text/Text.dart'; import '../../utils/Colors.dart'; import '../../utils/CommonUtils.dart'; import '../home/Home.dart'; import '../login/Login.dart'; import 'ConnectSplash.dart'; import 'Splash.dart'; import 'ViewSplash.dart'; class SplashState extends State implements ConnectSplash { ViewSplash? _model; Timer? _statusTimer; int _status = 0; /// Set once the session check has been asked for, so a tap and the timer /// never both fire it. bool _launched = false; /// What the loader says while it fills — the app's three jobs, in order. static const List _statuses = [ "Loading your plan", "Checking what's overdue", "Lining up today's routines", "Ready", ]; @override void dispose() { _statusTimer?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { return ViewModelBuilder.reactive( viewModelBuilder: () => ViewSplash(context, this), onViewModelReady: (viewModel) { _model = viewModel; _initiate(); }, builder: (context, viewModel, child) => PopScope( canPop: false, child: Scaffold( backgroundColor: colorAmbientMid, body: LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { return Responsive( mobile: _mobileView(constraints), tablet: _mobileView(constraints), desktop: _mobileView(constraints), ); }, ), ), ), ); } Future _initiate() async { _statusTimer = Timer.periodic( const Duration(milliseconds: 1000), _onStatusTick); // Long enough to watch the sun come up and read the screen once; a tap // skips straight on. await Future.delayed(const Duration(milliseconds: 4200)); _launch(); } void _onStatusTick(Timer timer) { if (!mounted) { return; } setState(() { _status = (_status + 1).clamp(0, _statuses.length - 1); }); } void _onSkip() { _launch(); } void _launch() { if (_launched || !mounted) { return; } _launched = true; _statusTimer?.cancel(); _model?.initialize(); } String _greeting() { final int hour = DateTime.now().hour; if (hour < 5) { return "Still up"; } if (hour < 12) { return "Good morning"; } if (hour < 18) { return "Good afternoon"; } return "Good evening"; } Widget _mobileView(BoxConstraints constraints) { return GestureDetector( onTap: _onSkip, behavior: HitTestBehavior.opaque, child: AmbientBackground( child: Stack( children: [ _landscape(constraints), ..._motes(constraints), SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(24, 14, 24, 22), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _topRow(), const Spacer(flex: 2), _hero(), const SizedBox(height: 26), _wordmark(), const Spacer(flex: 2), _howItWorks(), const Spacer(flex: 1), _loader(), ], ), ), ), ], ), ), ); } /// The painted valley rising from the bottom edge while its sun comes up. Widget _landscape(BoxConstraints constraints) { return Positioned( left: 0, right: 0, bottom: 0, height: constraints.maxHeight * 0.42, child: ShaderMask( shaderCallback: (Rect bounds) => const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0x00000000), Color(0xFF000000)], stops: [0.0, 0.4], ).createShader(bounds), blendMode: BlendMode.dstIn, child: TweenAnimationBuilder( tween: Tween(begin: 0, end: 1), duration: const Duration(milliseconds: 3200), curve: Curves.easeOutCubic, builder: (context, lift, child) => SceneArt( palette: sceneWorkout, seed: 11, sunLift: lift, ), ), ) .animate() .fadeIn(duration: 900.ms) .slideY(begin: 0.3, end: 0, duration: 1400.ms, curve: Curves.easeOutCubic), ); } /// Specks of light drifting up out of the valley. List _motes(BoxConstraints constraints) { const List xs = [0.12, 0.27, 0.41, 0.58, 0.7, 0.84, 0.93]; return List.generate(xs.length, (index) { final double size = 4.0 + (index % 3) * 2; return Positioned( left: constraints.maxWidth * xs[index], bottom: constraints.maxHeight * (0.08 + (index % 4) * 0.05), child: IgnorePointer( child: Container( width: size, height: size, decoration: BoxDecoration( shape: BoxShape.circle, color: colorWhite, boxShadow: [ BoxShadow( color: colorWhite.withValues(alpha: 0.8), blurRadius: 8), ], ), ) .animate(onPlay: (controller) => controller.repeat()) .fadeIn(duration: 600.ms, delay: (index * 260).ms) .moveY( begin: 0, end: -140 - index * 12.0, duration: (2600 + index * 240).ms, delay: (index * 260).ms, curve: Curves.easeOut, ) .fadeOut( duration: 900.ms, delay: (2000 + index * 240).ms), ), ); }); } Widget _topRow() { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ const GroundedMark(size: 26), const SizedBox(width: 8), text("Grounded", 16, TextType.Medium, color: colorInk), ], ), GlassSurface( radius: BorderRadius.zero, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(CupertinoIcons.sun_max_fill, size: 13, color: colorStandingWarned), const SizedBox(width: 6), text(_greeting(), 12, TextType.Medium, color: colorInk), ], ), ), ], ).animate().fadeIn(duration: 600.ms, delay: 150.ms).slideY( begin: -0.4, end: 0, duration: 600.ms, delay: 150.ms); } /// The mark in breathing rings, with the four things the app holds you to /// floating around it. Widget _hero() { return SizedBox( height: 270, child: Stack( alignment: Alignment.center, children: [ for (int ring = 0; ring < 3; ring++) Container( width: 140, height: 140, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: colorWhite.withValues(alpha: 0.95), width: 1.4), ), ) .animate(onPlay: (controller) => controller.repeat()) .scale( begin: const Offset(1, 1), end: const Offset(1.85, 1.85), duration: 2700.ms, delay: (ring * 900).ms, curve: Curves.easeOutCubic, ) .fadeOut(duration: 2700.ms, delay: (ring * 900).ms), _mark(), _chip(const Alignment(-1, -1), sceneWorkout, kindIcon(RoutineKind.Workout), "Workout routines", 0), _chip(const Alignment(1, -0.62), sceneMeditation, kindIcon(RoutineKind.Meditation), "Breathing sessions", 1), _chip(const Alignment(-1, 0.62), sceneMustDo, kindIcon(RoutineKind.MustDo), "Must-do chains", 2), _chip(const Alignment(1, 1), goalScene(goalAccents[4]), CupertinoIcons.flag_fill, "Goals with deadlines", 3), ], ), ); } Widget _mark() { return GlassSurface( radius: BorderRadius.zero, padding: const EdgeInsets.all(10), child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.zero, boxShadow: [ BoxShadow( color: colorPrimary.withValues(alpha: 0.4), blurRadius: 40, offset: const Offset(0, 16), ), ], ), // The sun rises out of the ground line, then the arcs light. child: TweenAnimationBuilder( tween: Tween(begin: 0, end: 1), duration: const Duration(milliseconds: 1800), builder: (context, t, child) => GroundedMark( size: 128, rise: Curves.easeOutCubic.transform((t / 0.6).clamp(0.0, 1.0)), arcs: Curves.easeOut .transform(((t - 0.45) / 0.55).clamp(0.0, 1.0)), ), ), ), ) .animate() .scale( begin: const Offset(0.6, 0.6), end: const Offset(1, 1), duration: 900.ms, curve: Curves.easeOutBack, ) .fadeIn(duration: 500.ms); } /// A frosted chip with a painted thumbnail. It drifts in, then bobs. Widget _chip(Alignment alignment, List palette, IconData icon, String label, int index) { return Align( alignment: alignment, child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.zero, boxShadow: const [ BoxShadow( color: Color(0x142A3A30), blurRadius: 16, offset: Offset(0, 6), ), ], ), child: GlassSurface( radius: BorderRadius.zero, tint: colorGlassStrong, padding: const EdgeInsets.fromLTRB(4, 4, 12, 4), child: Row( mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: 28, height: 28, child: ClipOval( child: SceneArt( palette: palette, seed: 5 + index * 9, figure: icon, figureScale: 0.52, figureAlignment: const Alignment(0, 0.2), ), ), ), const SizedBox(width: 8), text(label, 12, TextType.Medium, color: colorInk), ], ), ), ) .animate() .fadeIn(duration: 500.ms, delay: (900 + index * 180).ms) .slideX( begin: alignment.x < 0 ? -0.3 : 0.3, end: 0, duration: 600.ms, delay: (900 + index * 180).ms, curve: Curves.easeOutCubic, ) .then() .animate(onPlay: (controller) => controller.repeat(reverse: true)) .moveY( begin: -3, end: 3, duration: (1800 + index * 260).ms, curve: Curves.easeInOut, ), ); } /// Two tones, like the greeting on the home screen; the name catches the /// light once as it lands. Widget _wordmark() { return Column( children: [ text("Grounded", 46, TextType.Medium, color: colorInk, align: TextAlign.center, letterSpacing: -1.2) .animate() .fadeIn(duration: 700.ms, delay: 350.ms) .slideY(begin: 0.3, end: 0, duration: 700.ms, delay: 350.ms, curve: Curves.easeOutCubic) .then(delay: 300.ms) .shimmer(duration: 1200.ms, color: colorLime), const SizedBox(height: 8), text("A to-do app that\ndoesn't believe you.", 21, TextType.Regular, color: colorInkSoft, align: TextAlign.center, height: 1.3) .animate() .fadeIn(duration: 700.ms, delay: 650.ms) .slideY(begin: 0.3, end: 0, duration: 700.ms, delay: 650.ms, curve: Curves.easeOutCubic), ], ); } /// The whole idea in three beats, on glass over the valley. Widget _howItWorks() { return GlassSurface( radius: BorderRadius.zero, tint: colorGlassStrong, padding: const EdgeInsets.fromLTRB(14, 14, 14, 14), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: _beat(CupertinoIcons.hand_raised_fill, "Commit", "Say it and set a window", 0)), _beatArrow(0), Expanded( child: _beat(CupertinoIcons.timer, "Do it", "Inside the window, with proof", 1)), _beatArrow(1), Expanded( child: _beat(CupertinoIcons.exclamationmark_triangle_fill, "Or owe it", "Misses turn into debt", 2)), ], ), ).animate().fadeIn(duration: 600.ms, delay: 1500.ms).slideY( begin: 0.25, end: 0, duration: 700.ms, delay: 1500.ms, curve: Curves.easeOutCubic); } Widget _beat(IconData icon, String title, String caption, int index) { final List tones = [ colorPrimary, colorKindMeditation, colorStandingGrounded, ]; return Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 36, height: 36, decoration: BoxDecoration( shape: BoxShape.circle, color: tones[index].withValues(alpha: 0.12), ), child: Icon(icon, size: 16, color: tones[index]), ) .animate() .scale( begin: const Offset(0.4, 0.4), end: const Offset(1, 1), duration: 500.ms, delay: (1700 + index * 250).ms, curve: Curves.easeOutBack, ), const SizedBox(height: 8), text(title, 13, TextType.Medium, color: colorInk), const SizedBox(height: 2), text(caption, 10, TextType.Regular, color: colorInkSoft, align: TextAlign.center, maxLines: 2), ], ); } Widget _beatArrow(int index) { return Padding( padding: const EdgeInsets.only(top: 12), child: Icon(CupertinoIcons.chevron_right, size: 12, color: colorInkSoft) .animate() .fadeIn(duration: 400.ms, delay: (1900 + index * 250).ms), ); } /// A glass capsule that fills while the session resolves, and a status /// line that walks through what is being checked. Widget _loader() { return Column( children: [ GlassSurface( radius: BorderRadius.zero, padding: const EdgeInsets.all(3), child: SizedBox( width: 150, height: 6, child: Align( alignment: Alignment.centerLeft, child: Container( width: 150, decoration: BoxDecoration( color: colorNight, borderRadius: BorderRadius.zero, ), ).animate().scaleX( begin: 0.04, end: 1, alignment: Alignment.centerLeft, duration: 4000.ms, curve: Curves.easeInOutCubic, ), ), ), ), const SizedBox(height: 10), AnimatedSwitcher( duration: const Duration(milliseconds: 300), transitionBuilder: (child, animation) => FadeTransition( opacity: animation, child: SlideTransition( position: Tween( begin: const Offset(0, 0.4), end: Offset.zero, ).animate(animation), child: child, ), ), child: KeyedSubtree( key: ValueKey(_status), child: text(_statuses[_status], 12, TextType.Medium, color: colorWhite), ), ), const SizedBox(height: 4), text("Tap anywhere to skip", 10, TextType.Regular, color: colorWhite.withValues(alpha: 0.7)), ], ).animate().fadeIn(duration: 500.ms, delay: 600.ms); } @override void launchHome(MeDescription value) { GroundedNavigation() .navigateToPage(NavigatorType.makeNewMain, const Home(), context); } @override void launchLogin() { GroundedNavigation() .navigateToPage(NavigatorType.makeNewMain, const Login(), context); } }