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
563 lines
17 KiB
Dart
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);
|
|
}
|
|
}
|