Files
GroundedHelper/frontend/lib/Grounded/see/splash/SplashState.dart
alvocool d3431ca5de Switch to Inter, round the corners softly and make the nav switch tabs
Inter replaces General Sans (Inter Display for titles and big numbers).
Corners come back from before the squaring commit, scaled to a soft set
(16 cards, 14 buttons, 12 rows, 8 chips) and applied to the screens built
since. The nav is now a cluster of round frosted buttons that switches
Today, Routines, Goals, Groups and Progress in place inside Home: no
pushed pages, no back arrows, and Android back returns to Today.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-09 07:08:46 +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(14),
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(16),
padding: const EdgeInsets.all(10),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
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(14),
boxShadow: const [
BoxShadow(
color: Color(0x142A3A30),
blurRadius: 16,
offset: Offset(0, 6),
),
],
),
child: GlassSurface(
radius: BorderRadius.circular(14),
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(16),
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(14),
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(14),
),
).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);
}
}