Files
GroundedHelper/frontend/lib/Grounded/see/splash/SplashState.dart
alvocool 36bc41509d Animate the splash and fix the web preview hanging on the logo
The splash now raises the sun in the mark and over a painted valley, floats
four feature chips around the mark, shimmers the wordmark, shows a three-beat
"how it works" card, drifts light motes upward and walks a status line
through the session check. A tap skips it.

The web build hung on the native splash because the asset manifest could not
be published: "assets/icons/splash_orb.png" is 27 characters, so its length
prefix in AssetManifest.bin is an ESC byte. Renamed it to splash_mark.png and
removed the unused anchor assets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-02 12:59:34 +03:00

563 lines
17 KiB
Dart

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<Splash> 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<String> _statuses = <String>[
"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<ViewSplash>.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<void> _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>[Color(0x00000000), Color(0xFF000000)],
stops: <double>[0.0, 0.4],
).createShader(bounds),
blendMode: BlendMode.dstIn,
child: TweenAnimationBuilder<double>(
tween: Tween<double>(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<Widget> _motes(BoxConstraints constraints) {
const List<double> xs = <double>[0.12, 0.27, 0.41, 0.58, 0.7, 0.84, 0.93];
return List<Widget>.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>[
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(
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.circular(46),
padding: const EdgeInsets.all(10),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(34),
boxShadow: <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<double>(
tween: Tween<double>(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<Color> 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: [
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.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<Color> tones = <Color>[
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.circular(99),
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.circular(99),
),
).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<Offset>(
begin: const Offset(0, 0.4),
end: Offset.zero,
).animate(animation),
child: child,
),
),
child: KeyedSubtree(
key: ValueKey<int>(_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);
}
}