diff --git a/frontend/assets/icons/logo.svg b/frontend/assets/icons/logo.svg deleted file mode 100644 index cca97ca..0000000 --- a/frontend/assets/icons/logo.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/frontend/assets/icons/splash.png b/frontend/assets/icons/splash.png deleted file mode 100644 index f034796..0000000 Binary files a/frontend/assets/icons/splash.png and /dev/null differ diff --git a/frontend/assets/icons/splash_orb.png b/frontend/assets/icons/splash_mark.png similarity index 100% rename from frontend/assets/icons/splash_orb.png rename to frontend/assets/icons/splash_mark.png diff --git a/frontend/lib/Grounded/designs/scene/SceneArt.dart b/frontend/lib/Grounded/designs/scene/SceneArt.dart index c806341..74e3500 100644 --- a/frontend/lib/Grounded/designs/scene/SceneArt.dart +++ b/frontend/lib/Grounded/designs/scene/SceneArt.dart @@ -26,6 +26,9 @@ class SceneArt extends StatelessWidget { /// Darkens the lower half so white type laid over it stays legible. final bool shade; + /// Lowers the sun behind the ridges; animate it for a sunrise. + final double sunLift; + const SceneArt({ super.key, required this.palette, @@ -34,6 +37,7 @@ class SceneArt extends StatelessWidget { this.figureScale = 0.5, this.figureAlignment = const Alignment(0.35, 0.15), this.shade = false, + this.sunLift = 1, }); @override @@ -50,7 +54,8 @@ class SceneArt extends StatelessWidget { child: Stack( fit: StackFit.expand, children: [ - CustomPaint(painter: ScenePainter(palette, seed)), + CustomPaint( + painter: ScenePainter(palette, seed, sunLift: sunLift)), if (figure != null) Align( alignment: figureAlignment, diff --git a/frontend/lib/Grounded/designs/scene/ScenePainter.dart b/frontend/lib/Grounded/designs/scene/ScenePainter.dart index 47653ac..107ff8d 100644 --- a/frontend/lib/Grounded/designs/scene/ScenePainter.dart +++ b/frontend/lib/Grounded/designs/scene/ScenePainter.dart @@ -11,7 +11,10 @@ class ScenePainter extends CustomPainter { final int seed; - ScenePainter(this.palette, this.seed); + /// 1 → the sun sits in its place; 0 → it is down behind the ridges. + final double sunLift; + + ScenePainter(this.palette, this.seed, {this.sunLift = 1}); @override void paint(Canvas canvas, Size size) { @@ -36,7 +39,7 @@ class ScenePainter extends CustomPainter { final double radius = math.min(w, h) * (0.13 + random.nextDouble() * 0.05); final Offset sun = Offset( w * (0.18 + random.nextDouble() * 0.64), - h * (0.26 + random.nextDouble() * 0.12), + h * (0.26 + random.nextDouble() * 0.12) + h * 0.5 * (1 - sunLift), ); canvas.drawCircle( sun, @@ -109,6 +112,8 @@ class ScenePainter extends CustomPainter { @override bool shouldRepaint(covariant ScenePainter oldDelegate) { - return oldDelegate.seed != seed || oldDelegate.palette != palette; + return oldDelegate.seed != seed || + oldDelegate.palette != palette || + oldDelegate.sunLift != sunLift; } } diff --git a/frontend/lib/Grounded/see/splash/SplashState.dart b/frontend/lib/Grounded/see/splash/SplashState.dart index 6c13738..c8b5117 100644 --- a/frontend/lib/Grounded/see/splash/SplashState.dart +++ b/frontend/lib/Grounded/see/splash/SplashState.dart @@ -1,11 +1,13 @@ 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'; @@ -15,6 +17,7 @@ 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'; @@ -24,6 +27,28 @@ 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( @@ -51,136 +76,329 @@ class SplashState extends State implements ConnectSplash { } Future _initiate() async { - // Long enough for the mark to land and take one breath, then the session - // decides where we land. - await Future.delayed(const Duration(milliseconds: 2200)); + _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 AmbientBackground( - child: Stack( - children: [ - // The landscape rising from the bottom edge — the same painted world - // the routine cards live in. - Positioned( - left: 0, - right: 0, - bottom: 0, - height: constraints.maxHeight * 0.34, - child: ShaderMask( - shaderCallback: (Rect bounds) => const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0x00000000), Color(0xFF000000)], - stops: [0.0, 0.45], - ).createShader(bounds), - blendMode: BlendMode.dstIn, - child: const SceneArt(palette: sceneWorkout, seed: 11), - ) - .animate() - .fadeIn(duration: 1200.ms, curve: Curves.easeOut) - .slideY(begin: 0.25, end: 0, duration: 1400.ms, - curve: Curves.easeOutCubic), - ), - SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(28, 18, 28, 32), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - text("Grounded", 17, TextType.Regular, color: colorInk), - text("Daily discipline", 17, TextType.Regular, - color: colorInkSoft), - ], - ).animate().fadeIn(duration: 600.ms, delay: 200.ms), - const Spacer(flex: 3), - Center(child: _mark()), - const SizedBox(height: 40), - _wordmark(), - const Spacer(flex: 4), - _loader(), - ], + 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 mark on glass, inside rings that breathe outward. - Widget _mark() { + /// 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.circular(99), + 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( - width: 230, - height: 230, + height: 270, child: Stack( alignment: Alignment.center, children: [ for (int ring = 0; ring < 3; ring++) Container( - width: 120, - height: 120, + width: 140, + height: 140, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( - color: colorWhite.withValues(alpha: 0.9), width: 1.4), + color: colorWhite.withValues(alpha: 0.95), width: 1.4), ), ) .animate(onPlay: (controller) => controller.repeat()) .scale( begin: const Offset(1, 1), - end: const Offset(1.9, 1.9), - duration: 2400.ms, - delay: (ring * 800).ms, + end: const Offset(1.85, 1.85), + duration: 2700.ms, + delay: (ring * 900).ms, curve: Curves.easeOutCubic, ) - .fadeOut(duration: 2400.ms, delay: (ring * 800).ms), - GlassSurface( - radius: BorderRadius.circular(44), - padding: const EdgeInsets.all(10), - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(32), - boxShadow: [ - BoxShadow( - color: colorPrimary.withValues(alpha: 0.35), - 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: 1700), - builder: (context, t, child) => GroundedMark( - size: 132, - 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), + .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), ], ), ); } - /// Two-tone, like the greeting on the home screen. + Widget _mark() { + return GlassSurface( + radius: BorderRadius.circular(46), + padding: const EdgeInsets.all(10), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(34), + 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.circular(99), + boxShadow: const [ + BoxShadow( + color: Color(0x142A3A30), + blurRadius: 16, + offset: Offset(0, 6), + ), + ], + ), + child: GlassSurface( + radius: BorderRadius.circular(99), + 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: [ @@ -189,9 +407,11 @@ class SplashState extends State implements ConnectSplash { .animate() .fadeIn(duration: 700.ms, delay: 350.ms) .slideY(begin: 0.3, end: 0, duration: 700.ms, delay: 350.ms, - curve: Curves.easeOutCubic), - const SizedBox(height: 10), - text("A to-do app that\ndoesn't believe you.", 22, TextType.Light, + 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) @@ -201,7 +421,80 @@ class SplashState extends State implements ConnectSplash { ); } - /// A glass capsule that fills while the session resolves. + /// The whole idea in three beats, on glass over the valley. + Widget _howItWorks() { + return GlassSurface( + radius: BorderRadius.circular(24), + 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: [ @@ -209,33 +502,50 @@ class SplashState extends State implements ConnectSplash { radius: BorderRadius.circular(99), padding: const EdgeInsets.all(3), child: SizedBox( - width: 140, + width: 150, height: 6, child: Align( alignment: Alignment.centerLeft, child: Container( - width: 140, + width: 150, decoration: BoxDecoration( color: colorNight, borderRadius: BorderRadius.circular(99), ), - ) - .animate() - .scaleX( + ).animate().scaleX( begin: 0.04, end: 1, alignment: Alignment.centerLeft, - duration: 2000.ms, + duration: 4000.ms, curve: Curves.easeInOutCubic, ), ), ), ), - const SizedBox(height: 12), - text("Checking what you promised", 12, TextType.Regular, - color: colorWhite.withValues(alpha: 0.9)), + 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: 900.ms); + ).animate().fadeIn(duration: 500.ms, delay: 600.ms); } @override diff --git a/frontend/lib/Grounded/utils/Images.dart b/frontend/lib/Grounded/utils/Images.dart index 108789a..774f3c0 100644 --- a/frontend/lib/Grounded/utils/Images.dart +++ b/frontend/lib/Grounded/utils/Images.dart @@ -3,7 +3,7 @@ const String imagePath = "assets/images"; const String iconPath = "assets/icons"; /// The mark on a night disc, as the native splash shows it. -const String splashMark = "$iconPath/splash_orb.png"; +const String splashMark = "$iconPath/splash_mark.png"; const String logoMark = "$iconPath/icon.png"; diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index dfd45e3..60484de 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -122,10 +122,10 @@ flutter_launcher_icons: # the OS screen to the app is invisible. flutter_native_splash: color: "#EAEFE6" - image: "assets/icons/splash_orb.png" + image: "assets/icons/splash_mark.png" android_12: color: "#EAEFE6" - image: "assets/icons/splash_orb.png" + image: "assets/icons/splash_mark.png" icon_background_color: "#EAEFE6" android: true ios: true diff --git a/frontend/web/index.html b/frontend/web/index.html index e54d59a..f2e3b1f 100644 --- a/frontend/web/index.html +++ b/frontend/web/index.html @@ -34,6 +34,8 @@ + +