Redesign the UI after the fitness reference and replace the logo

Light sage-to-cream ambient backdrop, frosted glass controls and painted
SceneArt landscapes in place of photography. Home becomes a dashboard with a
two-tone greeting, a frosted summary ring, category portraits, an up-next rail
of image-led cards and a floating glass tab bar. The routine library gets an
image header and painted cards; the player gets a scene stage, steps on an
arc, a dotted clock and a night pill with a lime spark. Goals become painted
cards in their own colour.

New logo: a lime sun resting on a ground line under two arcs. The splash
raises the sun and lights the arcs; launcher icons and native splash are
regenerated to match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
This commit is contained in:
alvocool
2026-11-02 12:33:13 +03:00
parent b31bc47cc0
commit fe268cb8aa
100 changed files with 3394 additions and 1387 deletions

View File

@@ -109,10 +109,12 @@ status field. That's what makes honest history and excuse analysis possible.
## 6. Design language ## 6. Design language
See `design.md`. In short: black chrome → white sheet with a 28 radius → See `design.md`. In short: sage-to-cream ambient backdrop, frosted glass
oversized **Light** display title → tiny grey all-caps labels above values. controls, painted `SceneArt` instead of photos, image-led cards, two-tone
greetings, night pills with a lime spark.
- Every screen is built from `Sheet` (`designs/Shell.dart`). - Screens are built from `Sheet` (`designs/Shell.dart`); Home, Routines and
the routine player compose `AmbientBackground` + glass directly.
- Display titles are `TextType.Light`. Bold is for labels and metrics only. - Display titles are `TextType.Light`. Bold is for labels and metrics only.
- Never raw `Text`/`TextStyle` — use `text()` from `designs/text/Text.dart`. - Never raw `Text`/`TextStyle` — use `text()` from `designs/text/Text.dart`.
- Never inline hex — use `utils/Colors.dart`. - Never inline hex — use `utils/Colors.dart`.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

@@ -6,9 +6,9 @@
<item name="android:windowFullscreen">false</item> <item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item> <item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item> <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#141414</item> <item name="android:windowSplashScreenBackground">#EAEFE6</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item> <item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
<item name="android:windowSplashScreenIconBackgroundColor">#141414</item> <item name="android:windowSplashScreenIconBackgroundColor">#EAEFE6</item>
</style> </style>
<!-- Theme applied to the Android Window as soon as the process has started. <!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your This theme determines the color of the Android Window while your

View File

@@ -6,9 +6,9 @@
<item name="android:windowFullscreen">false</item> <item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item> <item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item> <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#141414</item> <item name="android:windowSplashScreenBackground">#EAEFE6</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item> <item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
<item name="android:windowSplashScreenIconBackgroundColor">#141414</item> <item name="android:windowSplashScreenIconBackgroundColor">#EAEFE6</item>
</style> </style>
<!-- Theme applied to the Android Window as soon as the process has started. <!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your This theme determines the color of the Android Window while your

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="ic_launcher_background">#141414</color> <color name="ic_launcher_background">#18211C</color>
</resources> </resources>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -1,9 +1,34 @@
# Grounded — Design Language # Grounded — Design Language
Reference: black device chrome, a white content sheet that rises into it with a Reference: an airy fitness-app look. Sage mist drifting into cream behind
large corner radius, oversized light display titles, tiny grey all-caps metadata everything (`AmbientBackground`), frosted glass for controls and floating
labels, pill chips, and a lot of whitespace. The app reads as a record, not a chrome (`GlassSurface`, `glassCircle`), ink type with a soft grey second
dashboard. tone, painted landscapes instead of photography (`SceneArt`), a floating
glass tab bar with a dark round action button (`FloatingNav`), and one lime
spark on night-green for the primary control.
## Building blocks (`designs/`)
| Piece | Use |
|---|---|
| `AmbientBackground` | The backdrop for every light screen. |
| `GlassSurface` / `glassCircle` | Frosted panels and round controls; `onDark` over a scene or tinted stage. |
| `SceneArt` | Painted sky, sun and ridges with an optional figure glyph. Palettes come from `scenePalette(kind)`, `goalScene(accent)`, `classScene(class)`; seed with `sceneSeed(title)` so an item always wears the same picture. |
| `DotDigits` | The dotted clock in the routine player. |
| `FloatingNav` | Home's tab bar and action button. |
| `GroundedMark` | The logo, drawn — a lime sun on a ground line under two arcs. The launcher PNGs in `assets/icons/` were rendered from the same geometry. |
Cards are image-led where an item has a kind (routines, goals, tasks): scene on
top or as a thumbnail, white type over the shaded lower half, three light
numbers along the bottom. Greetings are two-tone: Medium ink, then Regular
`colorInkSoft`. Primary buttons are night pills with a lime icon.
`Sheet` is light (ambient) by default; passing `chrome`, `banner` or
`scene` gives a tinted stage with white type. Home, Routines and the player
build their own layouts from the same pieces.
The sections below describe the original record-style type scale, which still
applies to labels and metrics.
## Type — General Sans ## Type — General Sans

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 473 B

After

Width:  |  Height:  |  Size: 492 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 934 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 683 B

After

Width:  |  Height:  |  Size: 831 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 934 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import '../utils/Colors.dart';
/// The airy backdrop: sage mist at the top drifting to cream, with two soft
/// glows so the glass controls have something to blur.
class AmbientBackground extends StatelessWidget {
final Widget child;
const AmbientBackground({super.key, required this.child});
@override
Widget build(BuildContext context) {
return Stack(
children: [
const Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[
colorAmbientTop,
colorAmbientMid,
colorAmbientBottom,
],
stops: <double>[0.0, 0.45, 1.0],
),
),
),
),
Positioned(
top: -140,
right: -110,
child: _glow(380, colorAmbientGlowSage),
),
Positioned(
top: 300,
left: -170,
child: _glow(360, colorAmbientGlowWarm),
),
Positioned.fill(child: child),
],
);
}
Widget _glow(double size, Color color) {
return IgnorePointer(
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: <Color>[color, color.withValues(alpha: 0.0)],
),
),
),
);
}
}

View File

@@ -0,0 +1,19 @@
import 'package:flutter/widgets.dart';
/// Tells controls placed in a [Sheet]'s chrome whether they sit on the light
/// ambient backdrop or on a coloured stage, so they pick glass to match.
class ChromeTone extends InheritedWidget {
final bool light;
const ChromeTone({super.key, required this.light, required super.child});
static bool isLight(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<ChromeTone>()?.light ??
false;
}
@override
bool updateShouldNotify(covariant ChromeTone oldWidget) {
return oldWidget.light != light;
}
}

View File

@@ -61,7 +61,7 @@ Widget card({
EdgeInsets margin = EdgeInsets.zero, EdgeInsets margin = EdgeInsets.zero,
Color? background, Color? background,
Color? borderColor, Color? borderColor,
double radius = 16, double radius = 22,
VoidCallback? onTap, VoidCallback? onTap,
}) { }) {
final Widget body = Container( final Widget body = Container(
@@ -71,12 +71,12 @@ Widget card({
decoration: BoxDecoration( decoration: BoxDecoration(
color: background ?? colorCard, color: background ?? colorCard,
borderRadius: BorderRadius.circular(radius), borderRadius: BorderRadius.circular(radius),
border: Border.all(color: borderColor ?? colorBorder, width: 1), border: Border.all(color: borderColor ?? colorGlassBorder, width: 1),
boxShadow: const [ boxShadow: const [
BoxShadow( BoxShadow(
color: Color(0x08000000), color: Color(0x0F2A3A30),
blurRadius: 18, blurRadius: 24,
offset: Offset(0, 4), offset: Offset(0, 8),
), ),
], ],
), ),

View File

@@ -0,0 +1,175 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import '../utils/Colors.dart';
/// A clock face drawn as a string of dots traced along each digit's outline —
/// the runner's signature readout. Only digits and ':' are drawn.
class DotDigits extends StatelessWidget {
final String value;
/// Glyph height in logical pixels.
final double height;
final Color? color;
const DotDigits({
super.key,
required this.value,
this.height = 64,
this.color,
});
@override
Widget build(BuildContext context) {
final _DotDigitsPainter painter =
_DotDigitsPainter(value, height, color ?? colorInk);
return CustomPaint(size: painter.measure(), painter: painter);
}
}
class _DotDigitsPainter extends CustomPainter {
final String value;
final double height;
final Color color;
_DotDigitsPainter(this.value, this.height, this.color);
/// Glyphs live in a 1 × 1.6 box.
double get _scale => height / 1.6;
double get _digitWidth => _scale * 1.0;
double get _colonWidth => _scale * 0.5;
double get _tracking => _scale * 0.34;
Size measure() {
double width = 0;
for (int i = 0; i < value.length; i++) {
width = width + (value[i] == ':' ? _colonWidth : _digitWidth);
if (i != value.length - 1) {
width = width + _tracking;
}
}
return Size(width + height * 0.08, height + height * 0.08);
}
@override
void paint(Canvas canvas, Size size) {
final double dot = height * 0.036;
final double gap = height * 0.082;
final Paint paint = Paint()..color = color;
canvas.translate(height * 0.04, height * 0.04);
double x = 0;
for (int i = 0; i < value.length; i++) {
final String glyph = value[i];
if (glyph == ':') {
canvas.drawCircle(
Offset(x + _colonWidth / 2, _scale * 0.52), dot * 1.25, paint);
canvas.drawCircle(
Offset(x + _colonWidth / 2, _scale * 1.12), dot * 1.25, paint);
x = x + _colonWidth + _tracking;
continue;
}
final Path? outline = _glyph(glyph);
if (outline != null) {
final Path placed = outline.transform(
(Matrix4.identity()
..translateByDouble(x, 0, 0, 1)
..scaleByDouble(_scale, _scale, 1, 1))
.storage);
for (PathMetric metric in placed.computeMetrics()) {
final int count = (metric.length / gap).floor().clamp(1, 400);
final double step = metric.length / count;
final int last = metric.isClosed ? count - 1 : count;
for (int n = 0; n <= last; n++) {
final Tangent? tangent = metric.getTangentForOffset(n * step);
if (tangent != null) {
canvas.drawCircle(tangent.position, dot, paint);
}
}
}
}
x = x + _digitWidth + _tracking;
}
}
Path? _glyph(String glyph) {
switch (glyph) {
case '0':
return Path()
..addRRect(RRect.fromRectAndRadius(
const Rect.fromLTWH(0, 0, 1, 1.6), const Radius.circular(0.5)));
case '1':
return Path()
..moveTo(0.22, 0.32)
..lineTo(0.6, 0)
..lineTo(0.6, 1.6);
case '2':
return Path()
..moveTo(0.03, 0.4)
..cubicTo(0.06, 0.02, 0.97, 0.0, 0.97, 0.44)
..cubicTo(0.97, 0.8, 0.05, 1.08, 0.0, 1.6)
..lineTo(1.0, 1.6);
case '3':
return Path()
..moveTo(0.04, 0.26)
..cubicTo(0.2, -0.06, 0.95, 0.0, 0.95, 0.4)
..cubicTo(0.95, 0.68, 0.66, 0.78, 0.4, 0.78)
..cubicTo(0.76, 0.78, 1.0, 0.92, 1.0, 1.2)
..cubicTo(1.0, 1.66, 0.18, 1.7, 0.0, 1.34);
case '4':
return Path()
..moveTo(0.76, 1.6)
..lineTo(0.76, 0.0)
..lineTo(0.0, 1.12)
..lineTo(1.0, 1.12);
case '5':
return Path()
..moveTo(0.92, 0.0)
..lineTo(0.14, 0.0)
..lineTo(0.06, 0.72)
..cubicTo(0.36, 0.54, 1.0, 0.6, 1.0, 1.08)
..cubicTo(1.0, 1.66, 0.2, 1.7, 0.0, 1.36);
case '6':
return Path()
..moveTo(0.86, 0.14)
..cubicTo(0.44, -0.12, 0.0, 0.24, 0.0, 1.1)
..addOval(const Rect.fromLTWH(0.0, 0.66, 1.0, 0.94));
case '7':
return Path()
..moveTo(0.0, 0.0)
..lineTo(1.0, 0.0)
..lineTo(0.34, 1.6);
case '8':
return Path()
..addOval(const Rect.fromLTWH(0.08, 0.0, 0.84, 0.74))
..addOval(const Rect.fromLTWH(0.0, 0.74, 1.0, 0.86));
case '9':
return Path()
..addOval(const Rect.fromLTWH(0.0, 0.0, 1.0, 0.94))
..moveTo(1.0, 0.5)
..cubicTo(1.0, 1.36, 0.56, 1.72, 0.14, 1.46);
default:
return null;
}
}
@override
bool shouldRepaint(covariant _DotDigitsPainter oldDelegate) {
return oldDelegate.value != value ||
oldDelegate.height != height ||
oldDelegate.color != color;
}
}

View File

@@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import '../about/internal/application/TextType.dart';
import '../utils/Colors.dart';
import 'GlassSurface.dart';
import 'text/Text.dart';
typedef NavEntry = ({IconData icon, String label, VoidCallback onTap});
/// The floating glass tab bar with the dark round action button beside it.
class FloatingNav extends StatelessWidget {
final List<NavEntry> entries;
final int selected;
final IconData actionIcon;
final VoidCallback onAction;
const FloatingNav({
super.key,
required this.entries,
required this.selected,
required this.actionIcon,
required this.onAction,
});
@override
Widget build(BuildContext context) {
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
child: Row(
children: [
Expanded(
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(34),
boxShadow: const [
BoxShadow(
color: Color(0x1A1A1F1C),
blurRadius: 30,
offset: Offset(0, 10),
),
],
),
child: GlassSurface(
radius: BorderRadius.circular(34),
tint: colorGlassStrong,
padding: const EdgeInsets.all(6),
child: Row(
children: [
for (int index = 0; index < entries.length; index++)
Expanded(child: _tab(entries[index], index == selected)),
],
),
),
),
),
const SizedBox(width: 10),
GestureDetector(
onTap: onAction,
child: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const RadialGradient(
center: Alignment(-0.3, -0.4),
colors: <Color>[colorNightGlow, colorNight],
),
border: Border.all(color: colorWhite, width: 3),
boxShadow: const [
BoxShadow(
color: Color(0x331A1F1C),
blurRadius: 24,
offset: Offset(0, 10),
),
],
),
child: Icon(actionIcon, size: 24, color: colorLime),
),
),
],
),
),
);
}
Widget _tab(NavEntry entry, bool active) {
return GestureDetector(
onTap: entry.onTap,
behavior: HitTestBehavior.opaque,
child: AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(vertical: 9),
decoration: BoxDecoration(
color: active ? colorWhite : colorWhite.withValues(alpha: 0.0),
borderRadius: BorderRadius.circular(28),
boxShadow: active
? const [
BoxShadow(
color: Color(0x141A1F1C),
blurRadius: 12,
offset: Offset(0, 4),
),
]
: const [],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(entry.icon,
size: 19, color: active ? colorInk : colorInkSoft),
const SizedBox(height: 4),
text(entry.label, 10, active ? TextType.Medium : TextType.Regular,
color: active ? colorInk : colorInkSoft, maxLines: 1),
],
),
),
);
}
}

View File

@@ -0,0 +1,104 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import '../utils/Colors.dart';
/// Frosted glass: the background blurred through a translucent white panel
/// with a bright hairline edge. Controls and floating chrome are built from it.
class GlassSurface extends StatelessWidget {
final Widget child;
final BorderRadius radius;
final EdgeInsets padding;
final Color? tint;
/// Over a dark or coloured stage the panel goes smoky instead of milky.
final bool onDark;
final double blur;
const GlassSurface({
super.key,
required this.child,
this.radius = const BorderRadius.all(Radius.circular(20)),
this.padding = EdgeInsets.zero,
this.tint,
this.onDark = false,
this.blur = 18,
});
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: radius,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
child: Container(
padding: padding,
decoration: BoxDecoration(
color: tint ??
(onDark ? colorWhite.withValues(alpha: 0.16) : colorGlass),
borderRadius: radius,
border: Border.all(
color: onDark
? colorWhite.withValues(alpha: 0.22)
: colorGlassBorder,
width: 1,
),
),
child: child,
),
),
);
}
}
/// A round frosted control — back, close, bell, more.
Widget glassCircle(
IconData icon,
VoidCallback onTap, {
double size = 42,
bool onDark = false,
bool dotted = false,
Color? dotColor,
}) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Stack(
clipBehavior: Clip.none,
children: [
GlassSurface(
onDark: onDark,
radius: BorderRadius.circular(size),
child: SizedBox(
width: size,
height: size,
child: Icon(
icon,
size: size * 0.42,
color: onDark ? colorWhite : colorInk,
),
),
),
if (dotted)
Positioned(
top: 2,
right: 2,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: dotColor ?? colorAccent,
shape: BoxShape.circle,
border: Border.all(color: colorWhite, width: 1.5),
),
),
),
],
),
);
}

View File

@@ -0,0 +1,117 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../utils/Colors.dart';
/// The Grounded mark: a lime sun resting on a ground line beneath two
/// breathing arcs, on a night-green tile. Drawn, not bitmapped, so the splash
/// can raise the sun and light the arcs. Geometry matches the launcher icon.
class GroundedMark extends StatelessWidget {
final double size;
/// 0 → the sun is below the line; 1 → fully risen.
final double rise;
/// 0 → arcs hidden; 1 → both lit. The inner arc lights first.
final double arcs;
final bool tile;
const GroundedMark({
super.key,
this.size = 120,
this.rise = 1,
this.arcs = 1,
this.tile = true,
});
@override
Widget build(BuildContext context) {
return CustomPaint(
size: Size.square(size),
painter: _MarkPainter(rise, arcs, tile),
);
}
}
class _MarkPainter extends CustomPainter {
final double rise;
final double arcs;
final bool tile;
_MarkPainter(this.rise, this.arcs, this.tile);
@override
void paint(Canvas canvas, Size size) {
final double u = size.width / 1024;
final Rect bounds = Offset.zero & size;
if (tile) {
canvas.drawRRect(
RRect.fromRectAndRadius(bounds, Radius.circular(size.width * 0.235)),
Paint()
..shader = const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[colorNightLift, colorNight],
).createShader(bounds),
);
}
final double cx = 512 * u;
final double horizon = 630 * u;
// Arcs — inner then outer.
final List<double> radii = <double>[250, 330];
final List<double> alphas = <double>[0.6, 0.28];
for (int i = 0; i < 2; i++) {
final double lit = ((arcs * 2) - i).clamp(0.0, 1.0);
if (lit <= 0) {
continue;
}
final double r = radii[i] * u;
canvas.drawArc(
Rect.fromCircle(center: Offset(cx, horizon), radius: r - 15 * u),
math.pi * 198 / 180,
math.pi * 144 / 180,
false,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 30 * u
..strokeCap = StrokeCap.round
..color = colorAmbientMid.withValues(alpha: alphas[i] * lit),
);
}
// The sun, clipped at the horizon so it rises out of the ground.
final double radius = 168 * u;
canvas.save();
canvas.clipRect(Rect.fromLTRB(0, 0, size.width, horizon));
canvas.drawCircle(
Offset(cx, horizon + radius * 2 * (1 - rise)),
radius,
Paint()..color = colorLime,
);
canvas.restore();
// Ground line.
final double ground = horizon + 52 * u;
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTRB(250 * u, ground - 20 * u, 774 * u, ground + 20 * u),
Radius.circular(20 * u),
),
Paint()..color = colorAmbientMid,
);
}
@override
bool shouldRepaint(covariant _MarkPainter oldDelegate) {
return oldDelegate.rise != rise ||
oldDelegate.arcs != arcs ||
oldDelegate.tile != tile;
}
}

View File

@@ -1,15 +1,20 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../about/internal/application/TextType.dart'; import '../about/internal/application/TextType.dart';
import '../utils/Colors.dart'; import '../utils/Colors.dart';
import 'Component.dart'; import 'AmbientBackground.dart';
import 'ChromeTone.dart';
import 'GlassSurface.dart';
import 'scene/SceneArt.dart';
import 'text/Text.dart'; import 'text/Text.dart';
/// The house layout: black chrome at the top, a white sheet rising into it with /// The house layout. On the default ambient tone the screen floats on sage
/// a large corner radius. Every screen is built from this so the app reads as /// mist with frosted controls and ink type, and the content rises on a frosted
/// one object rather than a stack of pages. /// sheet. Screens that carry a colour — a goal, a standing tier — or a banner
/// get a tinted stage instead, with white type and smoky glass.
class Sheet extends StatelessWidget { class Sheet extends StatelessWidget {
/// Small all-caps label rendered in the black chrome, above the title. /// Small all-caps label rendered in the chrome, above the title.
final String eyebrow; final String eyebrow;
/// The chrome title — small and centred, not the display title. /// The chrome title — small and centred, not the display title.
@@ -22,7 +27,7 @@ class Sheet extends StatelessWidget {
/// Optional trailing control in the chrome. /// Optional trailing control in the chrome.
final Widget? action; final Widget? action;
/// Chrome colour. Defaults to near-black; standing screens tint it. /// Stage colour. Leaving it null (with no banner) gives the ambient tone.
final Color? chrome; final Color? chrome;
/// Rendered inside the chrome beneath the title — the standing strip. /// Rendered inside the chrome beneath the title — the standing strip.
@@ -30,6 +35,12 @@ class Sheet extends StatelessWidget {
final bool scrollable; final bool scrollable;
/// A painted landscape behind a tinted stage's header, in place of the
/// flat gradient.
final List<Color>? scene;
final int sceneSeed;
const Sheet({ const Sheet({
super.key, super.key,
required this.title, required this.title,
@@ -40,33 +51,39 @@ class Sheet extends StatelessWidget {
this.chrome, this.chrome,
this.banner, this.banner,
this.scrollable = true, this.scrollable = true,
this.scene,
this.sceneSeed = 0,
}); });
bool get _light => chrome == null && banner == null && scene == null;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Color chromeColor = chrome ?? colorPrimaryDark; final bool light = _light;
final Color stage = chrome ?? colorPrimaryDark;
final Color ink = light ? colorInk : colorWhite;
final Color soft =
light ? colorInkSoft : colorWhite.withValues(alpha: 0.5);
return Scaffold( final Widget header = SafeArea(
backgroundColor: chromeColor,
body: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SafeArea(
bottom: false, bottom: false,
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 16), padding: const EdgeInsets.fromLTRB(16, 6, 16, 18),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Row( Row(
children: [ children: [
SizedBox( SizedBox(
width: 44, width: 46,
child: onBack == null child: onBack == null
? null ? null
: _chromeButton( : Align(
Icons.arrow_back_ios_new_rounded, onBack!), alignment: Alignment.centerLeft,
child: glassCircle(
CupertinoIcons.arrow_left, onBack!,
onDark: !light),
),
), ),
Expanded( Expanded(
child: Column( child: Column(
@@ -77,24 +94,25 @@ class Sheet extends StatelessWidget {
eyebrow.toUpperCase(), eyebrow.toUpperCase(),
9, 9,
TextType.Bold, TextType.Bold,
color: colorWhite.withValues(alpha: 0.45), color: soft,
letterSpacing: 1.2, letterSpacing: 1.2,
align: TextAlign.center, align: TextAlign.center,
), ),
const SizedBox(height: 3), const SizedBox(height: 3),
], ],
text( text(
title, title.toUpperCase(),
15, 14,
TextType.Medium, TextType.Medium,
color: colorWhite, color: ink,
letterSpacing: 0.6,
align: TextAlign.center, align: TextAlign.center,
), ),
], ],
), ),
), ),
SizedBox( SizedBox(
width: 44, width: 46,
child: action == null child: action == null
? null ? null
: Align( : Align(
@@ -105,20 +123,21 @@ class Sheet extends StatelessWidget {
], ],
), ),
if (banner != null) ...[ if (banner != null) ...[
const SizedBox(height: 16), const SizedBox(height: 18),
banner!, banner!,
], ],
], ],
), ),
), ),
), );
Expanded(
final Widget body = Expanded(
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorPrimaryLight, color: light ? colorFrost : colorPrimaryLight,
borderRadius: const BorderRadius.only( borderRadius: const BorderRadius.only(
topLeft: Radius.circular(28), topLeft: Radius.circular(32),
topRight: Radius.circular(28), topRight: Radius.circular(32),
), ),
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
@@ -132,66 +151,107 @@ class Sheet extends StatelessWidget {
child: child, child: child,
), ),
), ),
);
final Widget column = ChromeTone(
light: light,
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [header, body],
), ),
);
if (light) {
return Scaffold(
backgroundColor: colorAmbientMid,
body: AmbientBackground(child: column),
);
}
// A tinted stage: the colour deepens downward and a soft light blooms in
// the corner, so it reads as lit rather than painted flat.
return Scaffold(
backgroundColor: stage,
body: Stack(
children: [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[
Color.lerp(stage, colorWhite, 0.14)!,
stage,
Color.lerp(stage, colorBlack, 0.35)!,
],
),
),
),
),
if (scene != null)
Positioned(
top: 0,
left: 0,
right: 0,
height: 250,
child: ShaderMask(
shaderCallback: (Rect bounds) => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[Color(0xFF000000), Color(0x00000000)],
stops: <double>[0.6, 1.0],
).createShader(bounds),
blendMode: BlendMode.dstIn,
child: Stack(
fit: StackFit.expand,
children: [
SceneArt(palette: scene!, seed: sceneSeed),
ColoredBox(color: colorBlack.withValues(alpha: 0.42)),
],
),
),
),
Positioned(
top: -120,
right: -90,
child: Container(
width: 300,
height: 300,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: <Color>[
colorWhite.withValues(alpha: 0.16),
colorWhite.withValues(alpha: 0.0),
],
),
),
),
),
Positioned.fill(child: column),
], ],
), ),
); );
} }
Widget _chromeButton(IconData icon, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 38,
height: 38,
alignment: Alignment.center,
decoration: BoxDecoration(
color: colorWhite.withValues(alpha: 0.10),
shape: BoxShape.circle,
),
child: Icon(icon, size: 15, color: colorWhite),
),
);
}
} }
/// A circular control for the black chrome, with an optional unread dot. /// A round frosted control for the chrome, with an optional unread dot. It
/// matches the stage it sits on.
Widget chromeAction( Widget chromeAction(
IconData icon, IconData icon,
VoidCallback onTap, { VoidCallback onTap, {
bool dotted = false, bool dotted = false,
Color? dotColor, Color? dotColor,
}) { }) {
return GestureDetector( return Builder(
onTap: onTap, builder: (BuildContext context) => glassCircle(
child: Stack( icon,
clipBehavior: Clip.none, onTap,
children: [ onDark: !ChromeTone.isLight(context),
Container( dotted: dotted,
width: 38, dotColor: dotColor,
height: 38,
alignment: Alignment.center,
decoration: BoxDecoration(
color: colorWhite.withValues(alpha: 0.10),
shape: BoxShape.circle,
),
child: Icon(icon, size: 17, color: colorWhite),
),
if (dotted)
Positioned(
top: 1,
right: 1,
child: Container(
width: 9,
height: 9,
decoration: BoxDecoration(
color: dotColor ?? colorAccent,
shape: BoxShape.circle,
border: Border.all(color: colorPrimaryDark, width: 1.5),
),
),
),
],
), ),
); );
} }
@@ -215,12 +275,11 @@ Widget labelled(
label.toUpperCase(), label.toUpperCase(),
9, 9,
TextType.Bold, TextType.Bold,
color: labelColor ?? colorGrey2, color: labelColor ?? colorInkSoft,
letterSpacing: 1.0, letterSpacing: 1.0,
), ),
const SizedBox(height: 5), const SizedBox(height: 5),
text(value, valueSize, valueType, text(value, valueSize, valueType, color: valueColor ?? colorInk),
color: valueColor ?? colorPrimaryDark),
], ],
); );
} }
@@ -247,18 +306,15 @@ Widget metaRow(List<Widget> items) {
/// The oversized light display title that opens a section. /// The oversized light display title that opens a section.
Widget displayTitle(String value, {Color? color, double size = 34}) { Widget displayTitle(String value, {Color? color, double size = 34}) {
return text(value, size, TextType.Light, return text(value, size, TextType.Light,
color: color ?? colorPrimaryDark, height: 1.15); color: color ?? colorInk, height: 1.15);
} }
/// A section break: hairline, then a small bold heading with an optional /// A section heading: medium ink label, a soft caption beside it and an
/// trailing chip. /// optional trailing control ("View all").
Widget sectionBreak(String heading, {Widget? trailing, String caption = ""}) { Widget sectionBreak(String heading, {Widget? trailing, String caption = ""}) {
return Column( return Padding(
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.only(top: 6, bottom: 14),
mainAxisSize: MainAxisSize.min, child: Row(
children: [
hairline(margin: const EdgeInsets.only(bottom: 20)),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
@@ -266,13 +322,17 @@ Widget sectionBreak(String heading, {Widget? trailing, String caption = ""}) {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
text(heading, 17, TextType.Bold, color: colorPrimaryDark), text(heading, 16, TextType.Medium, color: colorInk),
if (caption.isNotEmpty) ...[ if (caption.isNotEmpty) ...[
const SizedBox(width: 10), const SizedBox(width: 8),
Padding( Flexible(
padding: const EdgeInsets.only(bottom: 2), child: Padding(
padding: const EdgeInsets.only(bottom: 1),
child: text(caption, 11, TextType.Regular, child: text(caption, 11, TextType.Regular,
color: colorGrey2), color: colorInkSoft,
maxLines: 1,
overflow: TextOverflow.ellipsis),
),
), ),
], ],
], ],
@@ -281,7 +341,5 @@ Widget sectionBreak(String heading, {Widget? trailing, String caption = ""}) {
if (trailing != null) trailing, if (trailing != null) trailing,
], ],
), ),
const SizedBox(height: 16),
],
); );
} }

View File

@@ -13,10 +13,10 @@ Widget roundedCornerButton(
Color? foreground, Color? foreground,
IconData? icon, IconData? icon,
bool enabled = true, bool enabled = true,
double radius = 14, double radius = 99,
double verticalPadding = 16, double verticalPadding = 16,
}) { }) {
final Color bg = enabled ? (background ?? colorPrimaryDark) : colorGrey; final Color bg = enabled ? (background ?? colorNight) : colorGrey;
final Color fg = foreground ?? colorWhite; final Color fg = foreground ?? colorWhite;
return ElevatedButton( return ElevatedButton(
@@ -35,10 +35,13 @@ Widget roundedCornerButton(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
if (icon != null) ...[ if (icon != null) ...[
Icon(icon, size: 16, color: fg), // The default night pill carries the lime spark on its icon.
Icon(icon,
size: 16,
color: background == null && foreground == null ? colorLime : fg),
const SizedBox(width: 8), const SizedBox(width: 8),
], ],
text(label, 14, TextType.Bold, color: fg), text(label, 14, TextType.Medium, color: fg),
], ],
), ),
); );
@@ -52,7 +55,7 @@ Widget outlinedActionButton(
Color? foreground, Color? foreground,
IconData? icon, IconData? icon,
bool enabled = true, bool enabled = true,
double radius = 14, double radius = 99,
}) { }) {
final Color fg = enabled ? (foreground ?? colorPrimaryDark) : colorGrey; final Color fg = enabled ? (foreground ?? colorPrimaryDark) : colorGrey;

View File

@@ -0,0 +1,114 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../utils/Colors.dart';
import 'ScenePainter.dart';
/// A painted landscape that stands in for photography: sky, a low sun, three
/// ridges and an oversized figure glyph standing in front of them. The [seed]
/// reshapes the ridges and moves the sun, so two routines of the same kind
/// never wear the identical picture.
class SceneArt extends StatelessWidget {
/// sky top, horizon, sun, far ridge, mid ridge, near ridge, ember.
final List<Color> palette;
final int seed;
/// The glyph standing in the scene — the kind's icon, usually.
final IconData? figure;
/// Figure size as a fraction of the shorter side.
final double figureScale;
final Alignment figureAlignment;
/// Darkens the lower half so white type laid over it stays legible.
final bool shade;
const SceneArt({
super.key,
required this.palette,
this.seed = 0,
this.figure,
this.figureScale = 0.5,
this.figureAlignment = const Alignment(0.35, 0.15),
this.shade = false,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final double shortest = math.min(
constraints.maxWidth.isFinite ? constraints.maxWidth : 200,
constraints.maxHeight.isFinite ? constraints.maxHeight : 200,
);
final double glyph = shortest * figureScale;
return RepaintBoundary(
child: Stack(
fit: StackFit.expand,
children: [
CustomPaint(painter: ScenePainter(palette, seed)),
if (figure != null)
Align(
alignment: figureAlignment,
child: SizedBox(
width: glyph * 1.6,
height: glyph * 1.6,
child: Stack(
alignment: Alignment.center,
children: [
// The ember halo behind the figure — the reference's
// orange gi, reduced to light.
Container(
width: glyph * 1.5,
height: glyph * 1.5,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: <Color>[
palette[6].withValues(alpha: 0.45),
palette[6].withValues(alpha: 0.0),
],
),
),
),
Icon(
figure,
size: glyph,
color: colorWhite.withValues(alpha: 0.96),
shadows: <Shadow>[
Shadow(
color: palette[5].withValues(alpha: 0.55),
blurRadius: glyph * 0.22,
offset: Offset(0, glyph * 0.08),
),
],
),
],
),
),
),
if (shade)
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const <double>[0.35, 1.0],
colors: <Color>[
colorBlack.withValues(alpha: 0.0),
colorBlack.withValues(alpha: 0.55),
],
),
),
),
],
),
);
},
);
}
}

View File

@@ -0,0 +1,114 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../utils/Colors.dart';
/// Paints the landscape behind [SceneArt]. Everything is derived from the
/// palette and the seed, so the same routine always gets the same picture.
class ScenePainter extends CustomPainter {
final List<Color> palette;
final int seed;
ScenePainter(this.palette, this.seed);
@override
void paint(Canvas canvas, Size size) {
final double w = size.width;
final double h = size.height;
final math.Random random = math.Random(seed);
final Rect bounds = Offset.zero & size;
// Sky.
canvas.drawRect(
bounds,
Paint()
..shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[palette[0], palette[1]],
stops: const <double>[0.0, 0.62],
).createShader(bounds),
);
// Sun, with a wide soft glow.
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),
);
canvas.drawCircle(
sun,
radius * 2.6,
Paint()
..shader = RadialGradient(
colors: <Color>[
palette[2].withValues(alpha: 0.55),
palette[2].withValues(alpha: 0.0),
],
).createShader(Rect.fromCircle(center: sun, radius: radius * 2.6)),
);
canvas.drawCircle(sun, radius, Paint()..color = palette[2]);
// A haze band along the horizon so the far ridge sits back.
final Rect haze = Rect.fromLTWH(0, h * 0.42, w, h * 0.22);
canvas.drawRect(
haze,
Paint()
..shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[
colorWhite.withValues(alpha: 0.0),
colorWhite.withValues(alpha: 0.35),
colorWhite.withValues(alpha: 0.0),
],
).createShader(haze),
);
// Three ridges, far to near.
final List<double> baselines = <double>[0.56, 0.68, 0.82];
final List<double> amplitudes = <double>[0.07, 0.06, 0.05];
for (int layer = 0; layer < 3; layer++) {
final double base = h * baselines[layer];
final double amp = h * amplitudes[layer];
final double f1 = 0.8 + random.nextDouble() * 1.4;
final double f2 = 2.0 + random.nextDouble() * 2.5;
final double p1 = random.nextDouble() * math.pi * 2;
final double p2 = random.nextDouble() * math.pi * 2;
final Path ridge = Path()..moveTo(0, h);
const int steps = 36;
for (int i = 0; i <= steps; i++) {
final double x = w * i / steps;
final double t = i / steps;
final double y = base -
amp *
(math.sin(t * f1 * math.pi * 2 + p1) * 0.65 +
math.sin(t * f2 * math.pi * 2 + p2) * 0.35);
ridge.lineTo(x, y);
}
ridge
..lineTo(w, h)
..close();
final Color tone = palette[3 + layer];
canvas.drawPath(
ridge,
Paint()
..shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[tone, Color.lerp(tone, colorBlack, 0.18)!],
).createShader(Rect.fromLTWH(0, base - amp, w, h - base + amp)),
);
}
}
@override
bool shouldRepaint(covariant ScenePainter oldDelegate) {
return oldDelegate.seed != seed || oldDelegate.palette != palette;
}
}

View File

@@ -16,6 +16,7 @@ import '../../designs/Component.dart';
import '../../designs/Responsive.dart'; import '../../designs/Responsive.dart';
import '../../designs/Shell.dart'; import '../../designs/Shell.dart';
import '../../designs/buttons/Buttons.dart'; import '../../designs/buttons/Buttons.dart';
import '../../designs/scene/SceneArt.dart';
import '../../designs/text/Text.dart'; import '../../designs/text/Text.dart';
import '../../utils/Colors.dart'; import '../../utils/Colors.dart';
import '../../utils/CommonUtils.dart'; import '../../utils/CommonUtils.dart';
@@ -181,6 +182,8 @@ class GoalDetailState extends State<GoalDetail>
title: _goal.title, title: _goal.title,
onBack: _onBack, onBack: _onBack,
chrome: _goal.colourHex.isEmpty ? null : goalColor(_goal.colourHex), chrome: _goal.colourHex.isEmpty ? null : goalColor(_goal.colourHex),
scene: goalScene(goalColor(_goal.colourHex)),
sceneSeed: sceneSeed(_goal.title),
action: chromeAction(CupertinoIcons.pencil, _onEditGoal), action: chromeAction(CupertinoIcons.pencil, _onEditGoal),
banner: _progressBanner(), banner: _progressBanner(),
child: Column( child: Column(
@@ -398,15 +401,19 @@ class GoalDetailState extends State<GoalDetail>
onTap: () => _onEditRoutine(routine), onTap: () => _onEditRoutine(routine),
child: Row( child: Row(
children: [ children: [
Container( SizedBox(
width: 48, width: 58,
height: 48, height: 58,
alignment: Alignment.center, child: ClipRRect(
decoration: BoxDecoration( borderRadius: BorderRadius.circular(16),
color: kindBackground(routine.kind), child: SceneArt(
borderRadius: BorderRadius.circular(13), palette: scenePalette(routine.kind),
seed: sceneSeed(routine.title),
figure: kindIcon(routine.kind),
figureScale: 0.46,
figureAlignment: const Alignment(0, 0.1),
),
), ),
child: Icon(kindIcon(routine.kind), size: 21, color: tone),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(

View File

@@ -5,9 +5,11 @@ import '../../about/external/data/Goal.dart';
import '../../about/internal/application/TextType.dart'; import '../../about/internal/application/TextType.dart';
import '../../configs/Navigator.dart'; import '../../configs/Navigator.dart';
import '../../designs/Component.dart'; import '../../designs/Component.dart';
import '../../designs/GlassSurface.dart';
import '../../designs/Responsive.dart'; import '../../designs/Responsive.dart';
import '../../designs/Shell.dart'; import '../../designs/Shell.dart';
import '../../designs/buttons/Buttons.dart'; import '../../designs/buttons/Buttons.dart';
import '../../designs/scene/SceneArt.dart';
import '../../designs/text/Text.dart'; import '../../designs/text/Text.dart';
import '../../utils/Colors.dart'; import '../../utils/Colors.dart';
import '../../utils/CommonUtils.dart'; import '../../utils/CommonUtils.dart';
@@ -104,109 +106,133 @@ class GoalsState extends State<Goals> implements ConnectGoals {
); );
} }
/// The goal as a painted card in its own colour: the ambition up top, the
/// progress and the cost along the bottom.
Widget _goalCard(Goal goal) { Widget _goalCard(Goal goal) {
final Color accent = final Color accent = goalColor(goal.colourHex);
goal.slipping ? colorStandingGrounded : goalColor(goal.colourHex); final Color soft = colorWhite.withValues(alpha: 0.78);
final bool overdue = goal.targetDate != null &&
goal.targetDate!.isBefore(DateTime.now());
return Container( return GestureDetector(
margin: const EdgeInsets.only(bottom: 12),
child: card(
onTap: () => _onOpenGoal(goal), onTap: () => _onOpenGoal(goal),
child: Column( child: Container(
crossAxisAlignment: CrossAxisAlignment.start, height: 214,
mainAxisSize: MainAxisSize.min, margin: const EdgeInsets.only(bottom: 14),
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration( decoration: BoxDecoration(
color: goalColor(goal.colourHex), borderRadius: BorderRadius.circular(26),
shape: BoxShape.circle, boxShadow: const [
BoxShadow(
color: Color(0x262A3A30),
blurRadius: 24,
offset: Offset(0, 10),
), ),
],
), ),
const SizedBox(width: 8), child: ClipRRect(
text( borderRadius: BorderRadius.circular(26),
child: Stack(
fit: StackFit.expand,
children: [
SceneArt(
palette: goalScene(accent),
seed: sceneSeed(goal.title),
figure: CupertinoIcons.flag_fill,
figureScale: 0.4,
figureAlignment: const Alignment(0.75, -0.35),
shade: true,
),
Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
GlassSurface(
onDark: true,
radius: BorderRadius.circular(99),
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 5),
child: text(
[ [
if (goal.category.isNotEmpty) goal.category, if (goal.category.isNotEmpty) goal.category,
goal.targetDate == null goal.targetDate == null
? "No deadline" ? "No deadline"
: targetLabel(goal.targetDate), : targetLabel(goal.targetDate),
].join(" · ").toUpperCase(), ].join(" · "),
9, 10,
TextType.Bold, TextType.Medium,
color: goal.targetDate != null && color: overdue ? colorTinted : colorWhite,
goal.targetDate!.isBefore(DateTime.now())
? colorStandingGrounded
: colorGrey2,
letterSpacing: 1.0,
), ),
),
const Spacer(),
if (goal.overdueTasks > 0)
pill("${goal.overdueTasks} late", colorWhite,
colorStandingGrounded,
textSize: 9),
], ],
), ),
const SizedBox(height: 8), const Spacer(),
text(goal.title, 19, TextType.Light, text(goal.title, 21, TextType.Medium,
color: colorPrimaryDark, color: colorWhite,
maxLines: 2, maxLines: 1,
overflow: TextOverflow.ellipsis), overflow: TextOverflow.ellipsis),
if (goal.description.isNotEmpty) ...[ if (goal.description.isNotEmpty) ...[
const SizedBox(height: 6), const SizedBox(height: 3),
text(goal.description, 12, TextType.Regular, text(goal.description, 12, TextType.Regular,
color: colorGrey2, color: soft,
maxLines: 2, maxLines: 1,
overflow: TextOverflow.ellipsis), overflow: TextOverflow.ellipsis),
], ],
],
),
),
const SizedBox(width: 10),
if (goal.overdueTasks > 0)
pill("${goal.overdueTasks} late", colorStandingGrounded,
colorStandingGroundedBg, textSize: 9),
],
),
const SizedBox(height: 18),
meter(goal.progress, fill: accent),
const SizedBox(height: 12), const SizedBox(height: 12),
meter(goal.progress,
fill: colorLime,
track: colorWhite.withValues(alpha: 0.25),
height: 5),
const SizedBox(height: 10),
Row( Row(
children: [ children: [
Expanded( Expanded(
child: labelled( child: _cardStat(
"${goal.completedTasks}/${goal.totalTasks}",
"Done", "Done",
"${goal.completedTasks} of ${goal.totalTasks}", soft),
valueSize: 13,
),
), ),
Expanded( Expanded(
child: labelled( child: _cardStat(
"Remaining", "${goal.remainingTasks}", "Remaining", soft),
"${goal.remainingTasks}",
valueSize: 13,
),
), ),
Expanded( Expanded(
child: labelled( child: _cardStat(
"Debt", formatDebt(goal.debtContribution), "Debt", soft,
formatDebt(goal.debtContribution), alert: goal.debtContribution > 0),
valueSize: 13,
valueColor: goal.debtContribution > 0
? colorStandingGrounded
: colorPrimaryDark,
),
), ),
], ],
), ),
], ],
), ),
), ),
],
),
),
),
);
}
Widget _cardStat(String value, String caption, Color tone,
{bool alert = false}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
text(value, 18, TextType.Light,
color: alert ? colorTinted : colorWhite),
const SizedBox(width: 5),
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: text(caption, 10, TextType.Regular, color: tone),
),
],
); );
} }

View File

@@ -1,5 +1,6 @@
import '../../about/external/data/Commitment.dart'; import '../../about/external/data/Commitment.dart';
import '../../about/external/data/ExcuseCluster.dart'; import '../../about/external/data/ExcuseCluster.dart';
import '../../about/external/data/RoutineChain.dart';
import '../../about/internal/application/Standing.dart'; import '../../about/internal/application/Standing.dart';
import '../../about/internal/application/UserDetails.dart'; import '../../about/internal/application/UserDetails.dart';
@@ -10,6 +11,9 @@ abstract class ConnectHome {
void onOverdueLoaded(List<Commitment> overdue); void onOverdueLoaded(List<Commitment> overdue);
/// The routine library, for the up-next rail.
void onRoutinesLoaded(List<RoutineChain> routines);
/// Standing arrives derived, with the debt it was derived from. /// Standing arrives derived, with the debt it was derived from.
void onStandingResolved(Standing standing, double debtScore); void onStandingResolved(Standing standing, double debtScore);

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import '../../about/external/data/Commitment.dart'; import '../../about/external/data/Commitment.dart';
import '../../about/external/data/ExcuseCluster.dart'; import '../../about/external/data/ExcuseCluster.dart';
import '../../about/external/data/RoutineChain.dart';
import '../../about/external/data/pages/request/CommitmentsRequest.dart'; import '../../about/external/data/pages/request/CommitmentsRequest.dart';
import '../../about/external/data/pages/request/HistoryRequest.dart'; import '../../about/external/data/pages/request/HistoryRequest.dart';
import '../../about/external/data/pages/request/PageAndSort.dart'; import '../../about/external/data/pages/request/PageAndSort.dart';
@@ -13,6 +14,7 @@ import '../../about/internal/application/Standing.dart';
import '../../about/internal/application/UserDetails.dart'; import '../../about/internal/application/UserDetails.dart';
import '../../utils/DebtEngine.dart'; import '../../utils/DebtEngine.dart';
import '../../utils/GuardrailEngine.dart'; import '../../utils/GuardrailEngine.dart';
import '../../utils/ObjectConvertors.dart';
import '../../utils/StandingEngine.dart'; import '../../utils/StandingEngine.dart';
import '../../utils/ToneEngine.dart'; import '../../utils/ToneEngine.dart';
import '../parent/ParentViewModel.dart'; import '../parent/ParentViewModel.dart';
@@ -52,11 +54,30 @@ class ViewHome extends ParentViewModel {
connection.onPlanLoaded(page.content); connection.onPlanLoaded(page.content);
loadOverdue(); loadOverdue();
loadRoutines();
} catch (e) { } catch (e) {
handleError(e, () => loadPlan(), () => dismissError(), "Retry"); handleError(e, () => loadPlan(), () => dismissError(), "Retry");
} }
} }
/// The routines feed the home rail. Quiet on failure — the plan is the
/// screen; the rail is a shortcut.
void loadRoutines() async {
try {
final response = await getDataManager().getMyRoutines(HistoryRequest(
query: PageAndSort(
sort: Sort('asc', 'startMinute'),
page: Pageable(0, 0, 50, 0),
),
));
connection.onRoutinesLoaded(getRoutineChainList(response.data));
} catch (e) {
connection.onRoutinesLoaded(<RoutineChain>[]);
}
}
void loadOverdue() async { void loadOverdue() async {
try { try {
final response = await getDataManager().getOverdueQueue(HistoryRequest( final response = await getDataManager().getOverdueQueue(HistoryRequest(

View File

@@ -12,10 +12,14 @@ import '../../about/internal/application/RoutinePhase.dart';
import '../../about/internal/application/RoutineRun.dart'; import '../../about/internal/application/RoutineRun.dart';
import '../../about/internal/application/StepMode.dart'; import '../../about/internal/application/StepMode.dart';
import '../../about/internal/application/TextType.dart'; import '../../about/internal/application/TextType.dart';
import '../../designs/AmbientBackground.dart';
import '../../designs/Component.dart'; import '../../designs/Component.dart';
import '../../designs/DotDigits.dart';
import '../../designs/GlassSurface.dart';
import '../../designs/ProgressRing.dart'; import '../../designs/ProgressRing.dart';
import '../../designs/Shell.dart'; import '../../designs/Shell.dart';
import '../../designs/buttons/Buttons.dart'; import '../../designs/buttons/Buttons.dart';
import '../../designs/scene/SceneArt.dart';
import '../../designs/text/Text.dart'; import '../../designs/text/Text.dart';
import '../../utils/Colors.dart'; import '../../utils/Colors.dart';
import '../../utils/CommonUtils.dart'; import '../../utils/CommonUtils.dart';
@@ -24,9 +28,9 @@ import 'ConnectRoutineRunner.dart';
import 'RoutineRunner.dart'; import 'RoutineRunner.dart';
import 'ViewRoutineRunner.dart'; import 'ViewRoutineRunner.dart';
/// The routine player. A coloured stage carries the phase — a ring that /// The routine player. A painted scene carries the phase — a glass ring that
/// drains for timed work, a breathing circle for meditation — and the sheet /// drains for timed work, a breathing circle for meditation — and the light
/// beneath it carries the chain: where you are, what is next, and the clock. /// panel beneath it carries the chain on an arc, the step and the dotted clock.
class RoutineRunnerState extends State<RoutineRunner> class RoutineRunnerState extends State<RoutineRunner>
with WidgetsBindingObserver, SingleTickerProviderStateMixin with WidgetsBindingObserver, SingleTickerProviderStateMixin
implements ConnectRoutineRunner { implements ConnectRoutineRunner {
@@ -50,7 +54,7 @@ class RoutineRunnerState extends State<RoutineRunner>
final ScrollController _carousel = ScrollController(); final ScrollController _carousel = ScrollController();
static const double _carouselItem = 76; static const double _carouselItem = 78;
/// Run time below which leaving logs nothing — opening the player by /// Run time below which leaving logs nothing — opening the player by
/// mistake is not a broken chain. /// mistake is not a broken chain.
@@ -94,7 +98,7 @@ class RoutineRunnerState extends State<RoutineRunner>
child: AnnotatedRegion<SystemUiOverlayStyle>( child: AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light, value: SystemUiOverlayStyle.light,
child: Scaffold( child: Scaffold(
backgroundColor: kindDeep(_run.chain.kind), backgroundColor: colorAmbientMid,
body: _finished ? _summaryView() : _runnerView(), body: _finished ? _summaryView() : _runnerView(),
), ),
), ),
@@ -230,10 +234,9 @@ class RoutineRunnerState extends State<RoutineRunner>
if (!_carousel.hasClients) { if (!_carousel.hasClients) {
return; return;
} }
final double viewport = _carousel.position.viewportDimension; // The list is padded by half a viewport either side, so step n is
final double target = (_run.phase.stepIndex * _carouselItem) - // centred at exactly n item-widths of scroll.
(viewport / 2) + final double target = _run.phase.stepIndex * _carouselItem;
(_carouselItem / 2);
_carousel.animateTo( _carousel.animateTo(
target.clamp(0, _carousel.position.maxScrollExtent), target.clamp(0, _carousel.position.maxScrollExtent),
@@ -354,41 +357,86 @@ class RoutineRunnerState extends State<RoutineRunner>
// ── Views ───────────────────────────────────────────────────────────────── // ── Views ─────────────────────────────────────────────────────────────────
Widget _runnerView() { Widget _runnerView() {
return Column( return AmbientBackground(
crossAxisAlignment: CrossAxisAlignment.stretch, child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final double stageHeight = constraints.maxHeight * 0.48;
return Stack(
children: [ children: [
Expanded(flex: 5, child: _stage()), Positioned(
Expanded(flex: 6, child: _panel()), top: 0,
left: 0,
right: 0,
height: stageHeight + 70,
child: _scene(),
),
Positioned(
top: 0,
left: 0,
right: 0,
height: stageHeight,
child: _stage(),
),
Positioned(
left: 0,
right: 0,
top: stageHeight - 40,
bottom: 0,
child: _panel(),
),
], ],
); );
},
),
);
} }
/// The top half: kind gradient, chrome controls and the phase visual. /// The painted world the run happens in. Rest dims it to dusk.
Widget _scene() {
return ShaderMask(
shaderCallback: (Rect bounds) => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[Color(0xFF000000), Color(0x00000000)],
stops: <double>[0.62, 1.0],
).createShader(bounds),
blendMode: BlendMode.dstIn,
child: Stack(
fit: StackFit.expand,
children: [
SceneArt(
palette: scenePalette(_run.chain.kind),
seed: sceneSeed(_run.chain.title),
),
AnimatedContainer(
duration: const Duration(milliseconds: 600),
color: colorNight.withValues(alpha: _run.phase.rest ? 0.45 : 0.08),
),
],
),
);
}
/// The top half: frosted controls and the phase visual over the scene.
Widget _stage() { Widget _stage() {
final RoutineKind kind = _run.chain.kind; final RoutineKind kind = _run.chain.kind;
return Container( return SafeArea(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[
_run.phase.rest ? colorPrimaryDark2 : kindColor(kind),
kindDeep(kind),
],
),
),
child: SafeArea(
bottom: false, bottom: false,
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 18), padding: const EdgeInsets.fromLTRB(16, 6, 16, 50),
child: Column( child: Column(
children: [ children: [
Row( Row(
children: [ children: [
chromeAction(CupertinoIcons.xmark, _onRequestExit), glassCircle(CupertinoIcons.arrow_left, _onRequestExit,
Expanded(child: Center(child: _totalPill())), onDark: true),
chromeAction(CupertinoIcons.list_bullet, _onOpenSteps), const Spacer(),
_totalPill(),
const SizedBox(width: 8),
glassCircle(CupertinoIcons.ellipsis_vertical, _onOpenSteps,
onDark: true),
], ],
), ),
Expanded( Expanded(
@@ -402,18 +450,15 @@ class RoutineRunnerState extends State<RoutineRunner>
], ],
), ),
), ),
),
); );
} }
/// Total elapsed and planned remaining — the reference's "Time" pill. /// Total elapsed and planned remaining — the reference's "Time" pill.
Widget _totalPill() { Widget _totalPill() {
return Container( return GlassSurface(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), onDark: true,
decoration: BoxDecoration( radius: BorderRadius.circular(99),
color: colorWhite.withValues(alpha: 0.12), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
borderRadius: BorderRadius.circular(999),
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -426,68 +471,59 @@ class RoutineRunnerState extends State<RoutineRunner>
height: 3, height: 3,
margin: const EdgeInsets.symmetric(horizontal: 8), margin: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorWhite.withValues(alpha: 0.45), color: colorWhite.withValues(alpha: 0.6),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
), ),
text("~${formatClock(_run.plannedRemaining())} left", 12, text("~${formatClock(_run.plannedRemaining())} left", 12,
TextType.Regular, TextType.Regular,
color: colorWhite.withValues(alpha: 0.6)), color: colorWhite.withValues(alpha: 0.8)),
], ],
), ),
); );
} }
/// A glass disc standing in the scene with the figure in it; the ring
/// drains as the phase runs down.
Widget _phaseRing() { Widget _phaseRing() {
final RoutinePhase phase = _run.phase; final RoutinePhase phase = _run.phase;
return Stack( return ProgressRing(
alignment: Alignment.center,
children: [
Container(
width: 236,
height: 236,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorWhite.withValues(alpha: 0.04),
),
),
ProgressRing(
fraction: phase.countsDown ? _run.phaseProgress() : 0, fraction: phase.countsDown ? _run.phaseProgress() : 0,
size: 208, size: 180,
stroke: 4, stroke: 5,
fill: colorWhite, fill: colorWhite,
track: colorWhite.withValues(alpha: 0.12), track: colorWhite.withValues(alpha: 0.22),
child: Container( child: GlassSurface(
width: 166, onDark: true,
height: 166, radius: BorderRadius.circular(100),
alignment: Alignment.center, child: SizedBox(
decoration: BoxDecoration( width: 150,
shape: BoxShape.circle, height: 150,
color: colorWhite.withValues(alpha: 0.08),
),
child: phase.rest child: phase.rest
? Column( ? Column(
mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(CupertinoIcons.wind, Icon(CupertinoIcons.wind, size: 38, color: colorWhite),
size: 34, color: colorWhite.withValues(alpha: 0.8)),
const SizedBox(height: 8), const SizedBox(height: 8),
text("REST", 11, TextType.Bold, text("Rest", 14, TextType.Medium, color: colorWhite),
color: colorWhite.withValues(alpha: 0.7),
letterSpacing: 2),
], ],
) )
: Icon( : Icon(
phase.step.mode == StepMode.Check phase.step.mode == StepMode.Check
? CupertinoIcons.checkmark_alt ? CupertinoIcons.checkmark_alt
: kindIcon(_run.chain.kind), : kindIcon(_run.chain.kind),
size: 64, size: 70,
color: colorWhite, color: colorWhite,
), shadows: const <Shadow>[
), Shadow(
), color: Color(0x40000000),
blurRadius: 18,
offset: Offset(0, 6)),
], ],
),
),
),
); );
} }
@@ -498,38 +534,44 @@ class RoutineRunnerState extends State<RoutineRunner>
final double t = Curves.easeInOut.transform(_breath.value); final double t = Curves.easeInOut.transform(_breath.value);
final bool inhaling = _breath.status == AnimationStatus.forward; final bool inhaling = _breath.status == AnimationStatus.forward;
return Stack( return SizedBox(
width: 240,
height: 240,
child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
Container( Container(
width: 150 + 90 * t, width: 140 + 96 * t,
height: 150 + 90 * t, height: 140 + 96 * t,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorWhite.withValues(alpha: 0.05),
),
),
Container(
width: 130 + 60 * t,
height: 130 + 60 * t,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: colorWhite.withValues(alpha: 0.10), color: colorWhite.withValues(alpha: 0.10),
border: Border.all( border: Border.all(
color: colorWhite.withValues(alpha: 0.25), width: 1), color: colorWhite.withValues(alpha: 0.35), width: 1),
), ),
), ),
text( GlassSurface(
onDark: true,
radius: BorderRadius.circular(200),
child: SizedBox(
width: 120 + 50 * t,
height: 120 + 50 * t,
child: Center(
child: text(
!_run.running !_run.running
? "Paused" ? "Paused"
: inhaling : inhaling
? "Breathe in" ? "Breathe in"
: "Breathe out", : "Breathe out",
17, 16,
TextType.Light, TextType.Medium,
color: colorWhite, color: colorWhite,
), ),
),
),
),
], ],
),
); );
}, },
); );
@@ -537,67 +579,59 @@ class RoutineRunnerState extends State<RoutineRunner>
Widget _stageStatus() { Widget _stageStatus() {
final String status = !_run.running final String status = !_run.running
? "PAUSED" ? "Paused"
: _run.phase.rest : _run.phase.rest
? "RESTING" ? "Resting"
: "IN PROGRESS"; : "In progress";
return Row( return Row(
children: [
GlassSurface(
onDark: true,
radius: BorderRadius.circular(99),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Container( Container(
width: 7, width: 7,
height: 7, height: 7,
decoration: BoxDecoration( decoration: BoxDecoration(
color: _run.running ? colorWhite : colorWarning, color: _run.running ? colorLime : colorWarning,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 7),
text(status, 9, TextType.Bold, text(status, 11, TextType.Medium, color: colorWhite),
color: colorWhite.withValues(alpha: 0.6), letterSpacing: 1.4),
if (_run.backgroundedCount > 0) ...[
const SizedBox(width: 10),
pill("Left ${_run.backgroundedCount}×", colorWarning,
colorWarning.withValues(alpha: 0.14),
textSize: 9),
], ],
const Spacer(),
GestureDetector(
onTap: _onTogglePause,
child: pill(
_run.running ? "Pause" : "Resume",
colorWhite,
colorWhite.withValues(alpha: 0.12),
icon: _run.running
? CupertinoIcons.pause_fill
: CupertinoIcons.play_fill,
textSize: 9,
), ),
), ),
if (_run.backgroundedCount > 0) ...[
const SizedBox(width: 8),
GlassSurface(
onDark: true,
radius: BorderRadius.circular(99),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
child: text("Left app ${_run.backgroundedCount}×", 11,
TextType.Medium,
color: colorWarmYellow),
),
],
], ],
); );
} }
/// The bottom sheet: chain carousel, step counter, clock and transport. /// The lower half: the chain on an arc, the step, the dotted clock and the
/// transport.
Widget _panel() { Widget _panel() {
final RoutinePhase phase = _run.phase; final RoutinePhase phase = _run.phase;
final Color tone = kindColor(_run.chain.kind); final Color tone = kindColor(_run.chain.kind);
return Container( return SafeArea(
decoration: BoxDecoration(
color: colorPrimaryLight,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(28),
topRight: Radius.circular(28),
),
),
child: SafeArea(
top: false, top: false,
child: Column( child: Column(
children: [ children: [
const SizedBox(height: 14),
_stepCarousel(), _stepCarousel(),
const SizedBox(height: 4),
Expanded( Expanded(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24), padding: const EdgeInsets.symmetric(horizontal: 24),
@@ -609,17 +643,17 @@ class RoutineRunnerState extends State<RoutineRunner>
children: [ children: [
text( text(
"${phase.stepIndex + 1} OF ${_run.chain.steps.length}", "${phase.stepIndex + 1} OF ${_run.chain.steps.length}",
10, 13,
TextType.Bold, TextType.Regular,
color: colorGrey2, color: colorInkSoft,
letterSpacing: 1.4, letterSpacing: 0.6,
), ),
const SizedBox(height: 6), const SizedBox(height: 4),
text( text(
phase.rest ? "Rest" : phase.step.title, phase.rest ? "Rest" : phase.step.title,
24, 24,
TextType.Light, TextType.Medium,
color: colorPrimaryDark, color: colorInk,
align: TextAlign.center, align: TextAlign.center,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -640,7 +674,6 @@ class RoutineRunnerState extends State<RoutineRunner>
const SizedBox(height: 14), const SizedBox(height: 14),
], ],
), ),
),
); );
} }
@@ -648,82 +681,96 @@ class RoutineRunnerState extends State<RoutineRunner>
final List<RoutineStep> steps = _run.chain.steps; final List<RoutineStep> steps = _run.chain.steps;
return SizedBox( return SizedBox(
height: 84, height: 104,
child: ListView.builder( child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final double side = (constraints.maxWidth - _carouselItem) / 2;
return ListView.builder(
controller: _carousel, controller: _carousel,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12), padding: EdgeInsets.symmetric(horizontal: side),
itemCount: steps.length, itemCount: steps.length,
itemExtent: _carouselItem, itemExtent: _carouselItem,
itemBuilder: (context, index) => _carouselStep(index, steps[index]), itemBuilder: (context, index) =>
_carouselStep(index, steps[index]),
);
},
), ),
); );
} }
/// Each step as a painted portrait. They sit on an arc that dips away from
/// the current one, like beads on a string.
Widget _carouselStep(int index, RoutineStep step) { Widget _carouselStep(int index, RoutineStep step) {
final bool current = index == _run.phase.stepIndex; final int current = _run.phase.stepIndex;
final bool isCurrent = index == current;
final bool done = step.completed; final bool done = step.completed;
final Color tone = kindColor(_run.chain.kind); final int distance = (index - current).abs();
final double size = current ? 50 : 40; final double drop = (distance * distance * 5.0).clamp(0, 30).toDouble();
final double size = isCurrent ? 58 : 46;
return GestureDetector( return GestureDetector(
onTap: () => _onJumpTo(index), onTap: () => _onJumpTo(index),
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
child: AnimatedPadding(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
padding: EdgeInsets.only(top: drop),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
AnimatedContainer( AnimatedContainer(
duration: const Duration(milliseconds: 250), duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic, curve: Curves.easeOutCubic,
margin: EdgeInsets.only(top: current ? 0 : 6),
width: size, width: size,
height: size, height: size,
alignment: Alignment.center, padding: const EdgeInsets.all(2.5),
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
gradient: current color: isCurrent ? colorNight : colorWhite,
? LinearGradient( boxShadow: const [
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[tone, kindDeep(_run.chain.kind)],
)
: null,
color: current ? null : (done ? tone.withValues(alpha: 0.12) : colorWhite),
border: Border.all(
color: current
? colorWhite
: done
? tone.withValues(alpha: 0.3)
: colorBorder,
width: current ? 3 : 1,
),
boxShadow: current
? [
BoxShadow( BoxShadow(
color: tone.withValues(alpha: 0.25), color: Color(0x262A3A30),
blurRadius: 12, blurRadius: 12,
offset: const Offset(0, 4), offset: Offset(0, 5),
), ),
] ],
: null,
), ),
child: done && !current child: ClipOval(
? Icon(Icons.check_rounded, size: 18, color: tone) child: Stack(
: text("${index + 1}", current ? 15 : 12, TextType.Bold, fit: StackFit.expand,
color: current ? colorWhite : colorGrey2), children: [
SceneArt(
palette: scenePalette(_run.chain.kind),
seed: sceneSeed(step.title),
figure: stepModeIcon(step.mode),
figureScale: 0.42,
figureAlignment: const Alignment(0, 0.2),
), ),
const SizedBox(height: 6), if (done && !isCurrent)
Container(
color: colorNight.withValues(alpha: 0.55),
child: Icon(Icons.check_rounded,
size: 20, color: colorLime),
),
],
),
),
),
const SizedBox(height: 5),
text( text(
step.title, step.title,
10, 10,
current ? TextType.Bold : TextType.Regular, isCurrent ? TextType.Medium : TextType.Regular,
color: current ? colorPrimaryDark : colorGrey2, color: isCurrent ? colorInk : colorInkSoft,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
align: TextAlign.center, align: TextAlign.center,
), ),
], ],
), ),
),
); );
} }
@@ -731,8 +778,8 @@ class RoutineRunnerState extends State<RoutineRunner>
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
text("SET ${phase.set} / ${phase.step.sets}", 9, TextType.Bold, text("Set ${phase.set} of ${phase.step.sets}", 11, TextType.Medium,
color: tone, letterSpacing: 1.0), color: tone),
const SizedBox(width: 10), const SizedBox(width: 10),
...List<Widget>.generate(phase.step.sets, (index) { ...List<Widget>.generate(phase.step.sets, (index) {
final int set = index + 1; final int set = index + 1;
@@ -742,11 +789,11 @@ class RoutineRunnerState extends State<RoutineRunner>
return AnimatedContainer( return AnimatedContainer(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
width: active ? 18 : 7, width: active ? 20 : 7,
height: 7, height: 7,
margin: const EdgeInsets.only(right: 4), margin: const EdgeInsets.only(right: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: filled || active ? tone : colorMuted, color: filled || active ? tone : tone.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
), ),
); );
@@ -755,36 +802,49 @@ class RoutineRunnerState extends State<RoutineRunner>
); );
} }
/// The dotted clock — counting down for timed work, up for everything else,
/// and the rep target for rep sets.
Widget _clock(RoutinePhase phase, Color tone) { Widget _clock(RoutinePhase phase, Color tone) {
final String label; final Color ink = _run.running ? colorInk : colorInkSoft;
final String value;
if (phase.countsDown) { if (!phase.countsDown && phase.step.mode == StepMode.Reps) {
label = phase.rest ? "REST REMAINING" : "REMAINING"; return Column(
value = formatClock(_run.phaseRemaining()); mainAxisSize: MainAxisSize.min,
} else if (phase.step.mode == StepMode.Reps) { children: [
label = "REPS · ${formatClock(_run.phaseElapsed())}"; Row(
value = "× ${phase.step.reps}"; mainAxisSize: MainAxisSize.min,
} else { crossAxisAlignment: CrossAxisAlignment.center,
label = "ON THIS TASK"; children: [
value = formatClock(_run.phaseElapsed()); text("×", 44, TextType.Light, color: ink),
const SizedBox(width: 10),
DotDigits(value: "${phase.step.reps}", height: 62, color: ink),
],
),
const SizedBox(height: 8),
text("reps · ${formatClock(_run.phaseElapsed())} on this set", 12,
TextType.Regular,
color: colorInkSoft),
],
);
} }
final int seconds =
phase.countsDown ? _run.phaseRemaining() : _run.phaseElapsed();
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
text(label, 9, TextType.Bold, DotDigits(value: formatClock(seconds), height: 62, color: ink),
color: colorGrey2, letterSpacing: 1.6),
const SizedBox(height: 4),
text(value, 64, TextType.Light,
color: _run.running ? colorPrimaryDark : colorGrey2, height: 1.0),
if (phase.countsDown) ...[ if (phase.countsDown) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
SizedBox( SizedBox(
width: 180, width: 180,
child: meter(_run.phaseProgress(), child: meter(_run.phaseProgress(),
fill: phase.rest ? colorGrey2 : tone, height: 4), fill: phase.rest ? colorInkSoft : tone, height: 4),
), ),
] else ...[
const SizedBox(height: 8),
text("on this task", 12, TextType.Regular, color: colorInkSoft),
], ],
], ],
); );
@@ -806,7 +866,7 @@ class RoutineRunnerState extends State<RoutineRunner>
} }
return text(line, 13, TextType.Regular, return text(line, 13, TextType.Regular,
color: colorGrey2, align: TextAlign.center, maxLines: 2, height: 1.4); color: colorInkSoft, align: TextAlign.center, maxLines: 2, height: 1.4);
} }
Widget _transport(RoutinePhase phase, Color tone) { Widget _transport(RoutinePhase phase, Color tone) {
@@ -819,8 +879,8 @@ class RoutineRunnerState extends State<RoutineRunner>
children: [ children: [
_roundControl( _roundControl(
_haptics _haptics
? CupertinoIcons.waveform ? CupertinoIcons.speaker_2
: CupertinoIcons.waveform_path_badge_minus, : CupertinoIcons.speaker_slash,
_onToggleHaptics, _onToggleHaptics,
), ),
_roundControl(CupertinoIcons.backward_end_fill, _onPrevious, _roundControl(CupertinoIcons.backward_end_fill, _onPrevious,
@@ -829,33 +889,39 @@ class RoutineRunnerState extends State<RoutineRunner>
onTap: _onPrimary, onTap: _onPrimary,
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
width: 118, width: 116,
height: 56, height: 58,
alignment: Alignment.center, alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorPrimaryDark, color: colorNight,
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
boxShadow: const [
BoxShadow(
color: Color(0x331A1F1C),
blurRadius: 18,
offset: Offset(0, 8),
),
],
), ),
child: countdown child: countdown
? Icon( ? Icon(
_run.running _run.running
? CupertinoIcons.pause_fill ? CupertinoIcons.pause_fill
: CupertinoIcons.play_fill, : CupertinoIcons.play_fill,
size: 22, size: 24,
color: colorKindWorkoutBg, color: colorLime,
) )
: Row( : Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.check_rounded, Icon(Icons.check_rounded, size: 18, color: colorLime),
size: 18, color: colorKindWorkoutBg),
const SizedBox(width: 6), const SizedBox(width: 6),
text( text(
phase.step.mode == StepMode.Reps phase.step.mode == StepMode.Reps
? "Set done" ? "Set done"
: "Done", : "Done",
13, 13,
TextType.Bold, TextType.Medium,
color: colorWhite, color: colorWhite,
), ),
], ],
@@ -871,23 +937,14 @@ class RoutineRunnerState extends State<RoutineRunner>
Widget _roundControl(IconData icon, VoidCallback onTap, Widget _roundControl(IconData icon, VoidCallback onTap,
{bool enabled = true}) { {bool enabled = true}) {
return GestureDetector( return Opacity(
onTap: enabled ? onTap : null, opacity: enabled ? 1 : 0.4,
child: Container( child: glassCircle(icon, enabled ? onTap : _onDisabled, size: 48),
width: 46,
height: 46,
alignment: Alignment.center,
decoration: BoxDecoration(
color: colorWhite,
shape: BoxShape.circle,
border: Border.all(color: colorBorder),
),
child: Icon(icon,
size: 17, color: enabled ? colorPrimaryDark : colorGrey),
),
); );
} }
void _onDisabled() {}
/// Every step with its state. Tap one to jump there. /// Every step with its state. Tap one to jump there.
Widget _stepsSheet(BuildContext sheetContext) { Widget _stepsSheet(BuildContext sheetContext) {
final Color tone = kindColor(_run.chain.kind); final Color tone = kindColor(_run.chain.kind);
@@ -977,107 +1034,138 @@ class RoutineRunnerState extends State<RoutineRunner>
/// End of run. Held or not, it is stated plainly and then logged. /// End of run. Held or not, it is stated plainly and then logged.
Widget _summaryView() { Widget _summaryView() {
final RoutineKind kind = _run.chain.kind;
final bool held = _run.chain.complete; final bool held = _run.chain.complete;
final int done = _run.chain.completedSteps; final int done = _run.chain.completedSteps;
final int total = _run.chain.steps.length; final int total = _run.chain.steps.length;
final bool broken = final bool broken =
!held && _run.chain.partialCompletion < Thresholds.routinePartialFloor; !held && _run.chain.partialCompletion < Thresholds.routinePartialFloor;
final Color label = colorWhite.withValues(alpha: 0.5);
return Container( return AmbientBackground(
decoration: BoxDecoration( child: Stack(
gradient: LinearGradient( children: [
Positioned(
top: 0,
left: 0,
right: 0,
height: 420,
child: ShaderMask(
shaderCallback: (Rect bounds) => const LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: <Color>[kindColor(kind), kindDeep(kind)], colors: <Color>[Color(0xFF000000), Color(0x00000000)],
stops: <double>[0.5, 1.0],
).createShader(bounds),
blendMode: BlendMode.dstIn,
child: SceneArt(
palette: scenePalette(_run.chain.kind),
seed: sceneSeed(_run.chain.title),
), ),
), ),
child: SafeArea( ),
SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(28, 20, 28, 24), padding: const EdgeInsets.fromLTRB(24, 20, 24, 24),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
text( Row(
held ? "CHAIN HELD" : (broken ? "CHAIN BROKEN" : "PARTIAL"), children: [
10, GlassSurface(
TextType.Bold, onDark: true,
color: label, radius: BorderRadius.circular(99),
letterSpacing: 1.6, padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 7),
child: text(
held
? "Chain held"
: (broken ? "Chain broken" : "Partial"),
12,
TextType.Medium,
color: colorWhite,
), ),
const SizedBox(height: 12), ),
text(_run.chain.title, 36, TextType.Light, ],
color: colorWhite, height: 1.1), ),
const SizedBox(height: 14), const Spacer(),
Center(
child: ProgressRing(
fraction: _run.chain.partialCompletion,
size: 190,
stroke: 6,
fill: held ? colorLime : colorWhite,
track: colorWhite.withValues(alpha: 0.3),
child: GlassSurface(
onDark: true,
radius: BorderRadius.circular(100),
child: SizedBox(
width: 158,
height: 158,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
text("$done/$total", 42, TextType.Light,
color: colorWhite),
text("steps", 12, TextType.Regular,
color: colorWhite.withValues(alpha: 0.8)),
],
),
),
),
),
),
const SizedBox(height: 36),
text(_run.chain.title, 32, TextType.Medium,
color: colorInk, height: 1.1, letterSpacing: -0.6),
const SizedBox(height: 8),
text( text(
held held
? "Every step, in order. That is the whole point." ? "Every step, in order. That is the whole point."
: "$done of $total steps. This goes in as partial — honest, not hidden.", : "$done of $total steps. This goes in as partial — honest, not hidden.",
14, 14,
TextType.Regular, TextType.Regular,
color: colorWhite.withValues(alpha: 0.7), color: colorInkSoft,
height: 1.5, height: 1.5,
), ),
const Spacer(), const SizedBox(height: 22),
Center( GlassSurface(
child: ProgressRing( radius: BorderRadius.circular(24),
fraction: _run.chain.partialCompletion, tint: colorGlassStrong,
size: 180, padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
stroke: 5, child: Row(
fill: colorWhite,
track: colorWhite.withValues(alpha: 0.14),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
text("$done/$total", 40, TextType.Light,
color: colorWhite),
text("STEPS", 9, TextType.Bold,
color: label, letterSpacing: 1.4),
],
),
),
),
const Spacer(),
Row(
children: [ children: [
Expanded( Expanded(
child: labelled("Time", formatClock(_run.elapsedSeconds()), child: labelled(
valueSize: 22, "Time", formatClock(_run.elapsedSeconds()),
valueType: TextType.Light, valueSize: 22, valueType: TextType.Light),
valueColor: colorWhite,
labelColor: label),
), ),
Expanded( Expanded(
child: labelled("Skipped", "${_run.skippedCount}", child: labelled("Skipped", "${_run.skippedCount}",
valueSize: 22, valueSize: 22, valueType: TextType.Light),
valueType: TextType.Light,
valueColor: colorWhite,
labelColor: label),
), ),
Expanded( Expanded(
child: labelled("Left app", "${_run.backgroundedCount}×", child: labelled(
"Left app", "${_run.backgroundedCount}×",
valueSize: 22, valueSize: 22,
valueType: TextType.Light, valueType: TextType.Light,
valueColor: _run.backgroundedCount > 0 valueColor: _run.backgroundedCount > 0
? colorWarning ? colorWarning
: colorWhite, : colorInk),
labelColor: label),
), ),
], ],
), ),
const SizedBox(height: 28), ),
const Spacer(),
roundedCornerButton( roundedCornerButton(
"Log it", "Log it",
_onLog, _onLog,
background: colorWhite,
foreground: colorPrimaryDark,
icon: CupertinoIcons.checkmark, icon: CupertinoIcons.checkmark,
), ),
], ],
), ),
), ),
), ),
],
),
); );
} }

View File

@@ -1,9 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../about/internal/application/RoutineKind.dart';
import 'RoutinesState.dart'; import 'RoutinesState.dart';
class Routines extends StatefulWidget { class Routines extends StatefulWidget {
const Routines({super.key}); /// Opens pre-filtered to one kind; null shows the whole library.
final RoutineKind? kind;
const Routines({super.key, this.kind});
@override @override
State<Routines> createState() => RoutinesState(); State<Routines> createState() => RoutinesState();

View File

@@ -8,11 +8,14 @@ import '../../about/internal/application/CommitmentClass.dart';
import '../../about/internal/application/RoutineKind.dart'; import '../../about/internal/application/RoutineKind.dart';
import '../../about/internal/application/TextType.dart'; import '../../about/internal/application/TextType.dart';
import '../../configs/Navigator.dart'; import '../../configs/Navigator.dart';
import '../../designs/AmbientBackground.dart';
import '../../designs/Component.dart'; import '../../designs/Component.dart';
import '../../designs/GlassSurface.dart';
import '../../designs/ProgressRing.dart'; import '../../designs/ProgressRing.dart';
import '../../designs/Responsive.dart'; import '../../designs/Responsive.dart';
import '../../designs/Shell.dart'; import '../../designs/Shell.dart';
import '../../designs/buttons/Buttons.dart'; import '../../designs/buttons/Buttons.dart';
import '../../designs/scene/SceneArt.dart';
import '../../designs/text/Text.dart'; import '../../designs/text/Text.dart';
import '../../utils/Colors.dart'; import '../../utils/Colors.dart';
import '../../utils/CommonUtils.dart'; import '../../utils/CommonUtils.dart';
@@ -53,6 +56,7 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
} }
void _initiate() { void _initiate() {
_kind = widget.kind;
_model?.loadRoutines(); _model?.loadRoutines();
} }
@@ -150,31 +154,35 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
// ── Views ───────────────────────────────────────────────────────────────── // ── Views ─────────────────────────────────────────────────────────────────
Widget _mobileView(BoxConstraints constraints) { Widget _mobileView(BoxConstraints constraints) {
return Sheet( return Scaffold(
eyebrow: "${_routines.length} routine${_routines.length == 1 ? "" : "s"}", backgroundColor: colorAmbientMid,
title: "Routines", body: AmbientBackground(
onBack: _onBack, child: SingleChildScrollView(
action: chromeAction(CupertinoIcons.add, _onBuild), padding: const EdgeInsets.only(bottom: 40),
banner: _routines.isEmpty ? null : _todayBanner(),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
text( _hero(),
"${weekdayName(DateTime.now().weekday).toUpperCase()} · ${_today.length} DUE", Padding(
11, padding: const EdgeInsets.fromLTRB(20, 0, 20, 0),
TextType.Bold, child: Column(
color: colorGrey2, crossAxisAlignment: CrossAxisAlignment.stretch,
letterSpacing: 1.2, mainAxisSize: MainAxisSize.min,
), children: [
const SizedBox(height: 8), if (_routines.isNotEmpty) ...[
displayTitle(_headline()), _todayStrip(),
const SizedBox(height: 24), const SizedBox(height: 26),
],
sectionBreak("Category",
caption: _kind == null ? "all kinds" : "tap again to clear"),
_kindRail(), _kindRail(),
const SizedBox(height: 28), const SizedBox(height: 22),
if (_filtered.isEmpty) if (_filtered.isEmpty)
emptyState( emptyState(
_kind == null ? CupertinoIcons.square_stack_3d_up : kindIcon(_kind!), _kind == null
? CupertinoIcons.square_stack_3d_up
: kindIcon(_kind!),
_kind == null _kind == null
? "No routines yet" ? "No routines yet"
: "No ${kindLabel(_kind!).toLowerCase()} routines", : "No ${kindLabel(_kind!).toLowerCase()} routines",
@@ -182,18 +190,19 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
) )
else ...[ else ...[
sectionBreak( sectionBreak(
"Up next", "Featured",
caption: _today.isEmpty ? "from your library" : "today", caption: _today.isEmpty ? "from your library" : "up next today",
), ),
_featuredRail(), _featuredRail(),
const SizedBox(height: 28), const SizedBox(height: 28),
if (_today.isNotEmpty) ...[ if (_today.isNotEmpty) ...[
sectionBreak( sectionBreak(
"Scheduled today", "Active today",
caption: "${_today.where((item) => item.complete).length} of ${_today.length} done", caption:
"${_today.where((item) => item.complete).length} of ${_today.length} done",
), ),
..._today.map(_routineRow), ..._today.map(_routineRow),
const SizedBox(height: 20), const SizedBox(height: 18),
], ],
if (_rest.isNotEmpty) ...[ if (_rest.isNotEmpty) ...[
sectionBreak("Library", caption: "${_rest.length} more"), sectionBreak("Library", caption: "${_rest.length} more"),
@@ -208,25 +217,103 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
), ),
], ],
), ),
),
],
),
),
),
);
}
/// The image header: a painted scene for the selected kind, fading into
/// the backdrop, with frosted controls and the day's headline on it.
Widget _hero() {
final RoutineKind shown = _kind ?? RoutineKind.Workout;
return SizedBox(
height: 330,
child: Stack(
fit: StackFit.expand,
children: [
ShaderMask(
shaderCallback: (Rect bounds) => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[Color(0xFF000000), Color(0x00000000)],
stops: <double>[0.55, 1.0],
).createShader(bounds),
blendMode: BlendMode.dstIn,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 450),
child: SceneArt(
key: ValueKey<RoutineKind>(shown),
palette: scenePalette(shown),
seed: 40 + shown.index,
figure: kindIcon(shown),
figureScale: 0.36,
figureAlignment: const Alignment(0.45, -0.1),
),
),
),
Positioned(
top: 0,
left: 0,
right: 0,
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 0),
child: Row(
children: [
glassCircle(CupertinoIcons.arrow_left, _onBack),
const Spacer(),
glassCircle(CupertinoIcons.add, _onBuild),
],
),
),
),
),
Positioned(
left: 20,
right: 20,
bottom: 18,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(
"${weekdayName(DateTime.now().weekday)} · ${_today.length} due",
13,
TextType.Regular,
color: colorInkSoft,
),
const SizedBox(height: 6),
text(_headline(), 32, TextType.Medium,
color: colorInk, height: 1.1, letterSpacing: -0.8),
],
),
),
],
),
); );
} }
String _headline() { String _headline() {
if (_routines.isEmpty) { if (_routines.isEmpty) {
return "Build the things you do every time."; return "Build the things\nyou do every time.";
} }
final int open = _today.where((item) => !item.complete).length; final int open = _today.where((item) => !item.complete).length;
if (_today.isEmpty) { if (_today.isEmpty) {
return "Nothing scheduled. Run one anyway."; return "Nothing scheduled.\nRun one anyway.";
} }
if (open == 0) { if (open == 0) {
return "Every chain held today."; return "Every chain\nheld today.";
} }
return "Here's what to hold today."; return "Here's what to\nhold today.";
} }
/// The strip in the black chrome: today's load at a glance. /// Today's load at a glance, on glass.
Widget _todayBanner() { Widget _todayStrip() {
final DateTime now = DateTime.now(); final DateTime now = DateTime.now();
final List<RoutineChain> due = final List<RoutineChain> due =
_routines.where((item) => item.scheduledOn(now)).toList(); _routines.where((item) => item.scheduledOn(now)).toList();
@@ -242,112 +329,117 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
.toList() .toList()
..sort((a, b) => a.startMinute.compareTo(b.startMinute)); ..sort((a, b) => a.startMinute.compareTo(b.startMinute));
final Color label = colorWhite.withValues(alpha: 0.45); return GlassSurface(
radius: BorderRadius.circular(24),
return Container( tint: colorGlassStrong,
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
decoration: BoxDecoration(
color: colorWhite.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16),
),
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: labelled("Due today", "${due.length}", child: _stripStat(CupertinoIcons.calendar_today, "${due.length}",
valueSize: 22, "Due today"),
valueType: TextType.Light,
valueColor: colorWhite,
labelColor: label),
), ),
Expanded( Expanded(
child: labelled("Planned", formatMinutes(seconds / 60), child: _stripStat(CupertinoIcons.clock,
valueSize: 22, formatMinutes(seconds / 60), "Planned"),
valueType: TextType.Light,
valueColor: colorWhite,
labelColor: label),
), ),
Expanded( Expanded(
child: labelled( child: _stripStat(
"Next", CupertinoIcons.arrow_right_circle,
ahead.isEmpty ? "—" : formatStartMinute(ahead.first.startMinute), ahead.isEmpty
valueSize: 22, ? "—"
valueType: TextType.Light, : formatStartMinute(ahead.first.startMinute),
valueColor: colorWhite, "Next"),
labelColor: label),
), ),
], ],
), ),
); );
} }
/// Round category tiles — tap to filter, tap again to clear. Widget _stripStat(IconData icon, String value, String label) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(value, 22, TextType.Light, color: colorInk),
const SizedBox(height: 3),
Row(
children: [
Icon(icon, size: 11, color: colorInkSoft),
const SizedBox(width: 4),
text(label, 10, TextType.Regular, color: colorInkSoft),
],
),
],
);
}
/// Round painted portraits — tap to filter, tap again to clear.
Widget _kindRail() { Widget _kindRail() {
final List<RoutineKind?> options = <RoutineKind?>[null, ...RoutineKind.values]; final List<RoutineKind?> options = <RoutineKind?>[null, ...RoutineKind.values];
return SizedBox( return Row(
height: 96, mainAxisAlignment: MainAxisAlignment.spaceBetween,
child: ListView.separated( children: options.map(_kindTile).toList(),
scrollDirection: Axis.horizontal,
itemCount: options.length,
separatorBuilder: (context, index) => const SizedBox(width: 14),
itemBuilder: (context, index) => _kindTile(options[index]),
),
); );
} }
Widget _kindTile(RoutineKind? kind) { Widget _kindTile(RoutineKind? kind) {
final bool selected = _kind == kind; final bool selected = _kind == kind;
final Color tone = kind == null ? colorPrimaryDark : kindColor(kind);
return GestureDetector( return GestureDetector(
onTap: () => _onSelectKind(kind), onTap: () => _onSelectKind(kind),
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
child: SizedBox( child: SizedBox(
width: 70, width: 74,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
AnimatedContainer( AnimatedContainer(
duration: const Duration(milliseconds: 180), duration: const Duration(milliseconds: 200),
curve: Curves.easeOut, curve: Curves.easeOut,
width: 60, width: 64,
height: 60, height: 64,
alignment: Alignment.center, padding: const EdgeInsets.all(2.5),
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
gradient: selected color: selected ? colorNight : colorWhite,
? LinearGradient( boxShadow: const [
begin: Alignment.topLeft, BoxShadow(
end: Alignment.bottomRight, color: Color(0x1F2A3A30),
colors: <Color>[ blurRadius: 14,
tone, offset: Offset(0, 6),
kind == null ? colorPrimaryDark2 : kindDeep(kind), ),
], ],
),
child: ClipOval(
child: kind == null
? Container(
color: colorWhite,
alignment: Alignment.center,
child: Icon(CupertinoIcons.square_grid_2x2_fill,
size: 22, color: colorInk),
) )
: null, : SceneArt(
color: selected ? null : colorWhite, palette: scenePalette(kind),
border: Border.all( seed: 3 + kind.index * 7,
color: selected ? tone : colorBorder, figure: kindIcon(kind),
width: selected ? 2 : 1, figureScale: 0.48,
figureAlignment: const Alignment(0, 0.25),
), ),
), ),
child: Icon(
kind == null ? CupertinoIcons.square_grid_2x2_fill : kindIcon(kind),
size: 22,
color: selected ? colorWhite : tone,
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
text( text(
kind == null ? "All" : kindLabel(kind), kind == null ? "All" : kindLabel(kind),
11, 11,
selected ? TextType.Bold : TextType.Regular, selected ? TextType.Medium : TextType.Regular,
color: selected ? colorPrimaryDark : colorGrey2, color: selected ? colorInk : colorInkSoft,
maxLines: 1, maxLines: 1,
), ),
const SizedBox(height: 2), const SizedBox(height: 1),
text("${_countFor(kind)}", 9, TextType.Bold, text("${_countFor(kind)}", 10, TextType.Regular,
color: colorGrey, letterSpacing: 0.6), color: colorInkSoft),
], ],
), ),
), ),
@@ -358,7 +450,7 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
final List<RoutineChain> items = _upNext; final List<RoutineChain> items = _upNext;
return SizedBox( return SizedBox(
height: 214, height: 240,
child: ListView.separated( child: ListView.separated(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
clipBehavior: Clip.none, clipBehavior: Clip.none,
@@ -369,129 +461,107 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
); );
} }
/// The large gradient card. The kind's icon sits oversized in the corner as /// The image-led card: the painted scene, a glass chip, the title and three
/// the card's picture — routines have no stock photography. /// light numbers along the bottom.
Widget _featuredCard(RoutineChain routine) { Widget _featuredCard(RoutineChain routine) {
final Color tone = kindColor(routine.kind); final Color label = colorWhite.withValues(alpha: 0.75);
final Color label = colorWhite.withValues(alpha: 0.55); final bool workout = routine.kind == RoutineKind.Workout;
return GestureDetector( return GestureDetector(
onTap: () => _onPreview(routine), onTap: () => _onPreview(routine),
child: Container( child: Container(
width: 248, width: 200,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(26),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[tone, kindDeep(routine.kind)],
),
boxShadow: const [ boxShadow: const [
BoxShadow( BoxShadow(
color: Color(0x08000000), color: Color(0x262A3A30),
blurRadius: 18, blurRadius: 24,
offset: Offset(0, 4), offset: Offset(0, 10),
), ),
], ],
), ),
clipBehavior: Clip.antiAlias, child: ClipRRect(
borderRadius: BorderRadius.circular(26),
child: Stack( child: Stack(
fit: StackFit.expand,
children: [ children: [
Positioned( SceneArt(
right: -26, palette: scenePalette(routine.kind),
bottom: 34, seed: sceneSeed(routine.title),
child: Icon( figure: kindIcon(routine.kind),
kindIcon(routine.kind), figureScale: 0.44,
size: 150, figureAlignment: const Alignment(0.1, -0.25),
color: colorWhite.withValues(alpha: 0.08), shade: true,
),
), ),
Padding( Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(12),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
children: [ children: [
pill(kindLabel(routine.kind), colorWhite, GlassSurface(
colorWhite.withValues(alpha: 0.14), onDark: true,
textSize: 9), radius: BorderRadius.circular(99),
if (routine.commitmentClass == padding: const EdgeInsets.symmetric(
CommitmentClass.NonNegotiable) ...[ horizontal: 10, vertical: 5),
const SizedBox(width: 6), child: text(
pill("Must hold", colorWhite, routine.commitmentClass ==
colorWhite.withValues(alpha: 0.14), CommitmentClass.NonNegotiable
textSize: 9), ? "Must hold"
], : kindLabel(routine.kind),
const Spacer(), 10,
Container( TextType.Medium,
width: 34,
height: 34,
alignment: Alignment.center,
decoration: BoxDecoration(
color: colorWhite, color: colorWhite,
shape: BoxShape.circle,
), ),
child: Icon(CupertinoIcons.play_fill,
size: 14, color: kindDeep(routine.kind)),
), ),
const Spacer(),
glassCircle(CupertinoIcons.play_fill,
() => _onStart(routine),
size: 32, onDark: true),
], ],
), ),
const Spacer(), const Spacer(),
if (routine.startMinute >= 0) ...[ if (routine.startMinute >= 0)
text( text(
"${formatStartMinute(routine.startMinute)} · ${formatDays(routine.days)}" "${formatStartMinute(routine.startMinute)} · ${formatDays(routine.days)}",
.toUpperCase(), 10,
9, TextType.Regular,
TextType.Bold,
color: label, color: label,
letterSpacing: 1.0, maxLines: 1,
), ),
const SizedBox(height: 6), const SizedBox(height: 3),
], text(routine.title, 17, TextType.Medium,
text(routine.title, 21, TextType.Light,
color: colorWhite, color: colorWhite,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis),
height: 1.15), const SizedBox(height: 10),
const SizedBox(height: 14),
Container(
height: 1,
color: colorWhite.withValues(alpha: 0.12),
),
const SizedBox(height: 12),
Row( Row(
children: [ children: [
Expanded( Expanded(
child: labelled( child: _cardStat(
"Minutes",
"${(routine.totalSeconds / 60).round()}", "${(routine.totalSeconds / 60).round()}",
valueSize: 17, CupertinoIcons.clock,
valueColor: colorWhite, "Min",
labelColor: label, label),
),
), ),
Expanded( Expanded(
child: labelled( child: _cardStat("${routine.steps.length}",
"Steps", CupertinoIcons.list_bullet, "Steps", label),
"${routine.steps.length}",
valueSize: 17,
valueColor: colorWhite,
labelColor: label,
),
), ),
Expanded( Expanded(
child: labelled( child: _cardStat(
routine.kind == RoutineKind.Workout ? "Sets" : "Starts", workout
routine.kind == RoutineKind.Workout
? "${routine.totalSets}" ? "${routine.totalSets}"
: routine.startMinute < 0 : routine.startMinute < 0
? "Any" ? "Any"
: formatStartMinute(routine.startMinute), : formatStartMinute(routine.startMinute),
valueSize: 17, workout
valueColor: colorWhite, ? CupertinoIcons.repeat
labelColor: label, : CupertinoIcons.alarm,
), workout ? "Sets" : "Starts",
label),
), ),
], ],
), ),
@@ -501,6 +571,25 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
], ],
), ),
), ),
),
);
}
Widget _cardStat(String value, IconData icon, String caption, Color tone) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(value, 20, TextType.Light, color: colorWhite, maxLines: 1),
const SizedBox(height: 2),
Row(
children: [
Icon(icon, size: 10, color: tone),
const SizedBox(width: 3),
text(caption, 9, TextType.Regular, color: tone),
],
),
],
); );
} }
@@ -508,7 +597,7 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
final Color tone = kindColor(routine.kind); final Color tone = kindColor(routine.kind);
final bool weighted = routine.commitmentClass != CommitmentClass.Standard; final bool weighted = routine.commitmentClass != CommitmentClass.Standard;
final List<String> eyebrow = <String>[ final List<String> kicker = <String>[
if (weighted) classLabel(routine.commitmentClass), if (weighted) classLabel(routine.commitmentClass),
if (routine.startMinute >= 0) formatStartMinute(routine.startMinute), if (routine.startMinute >= 0) formatStartMinute(routine.startMinute),
formatDays(routine.days), formatDays(routine.days),
@@ -517,45 +606,43 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
return Container( return Container(
margin: const EdgeInsets.only(bottom: 10), margin: const EdgeInsets.only(bottom: 10),
child: card( child: card(
padding: const EdgeInsets.fromLTRB(10, 10, 14, 10), padding: const EdgeInsets.fromLTRB(8, 8, 12, 8),
onTap: () => _onPreview(routine), onTap: () => _onPreview(routine),
child: Row( child: Row(
children: [ children: [
Container( SizedBox(
width: 58, width: 70,
height: 58, height: 70,
decoration: BoxDecoration( child: ClipRRect(
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(18),
gradient: LinearGradient( child: SceneArt(
begin: Alignment.topLeft, palette: scenePalette(routine.kind),
end: Alignment.bottomRight, seed: sceneSeed(routine.title),
colors: <Color>[kindBackground(routine.kind), colorWhite], figure: kindIcon(routine.kind),
figureScale: 0.46,
figureAlignment: const Alignment(0, 0.1),
), ),
border: Border.all(color: tone.withValues(alpha: 0.15)),
), ),
alignment: Alignment.center,
child: Icon(kindIcon(routine.kind), size: 24, color: tone),
), ),
const SizedBox(width: 14), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
text( text(
eyebrow.join(" · ").toUpperCase(), kicker.join(" · "),
9, 10,
TextType.Bold, TextType.Medium,
color: weighted color: weighted
? classColor(routine.commitmentClass) ? classColor(routine.commitmentClass)
: colorGrey2, : tone,
letterSpacing: 0.9,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 5), const SizedBox(height: 3),
text(routine.title, 15, TextType.Medium, text(routine.title, 15, TextType.Medium,
color: colorPrimaryDark, color: colorInk,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis), overflow: TextOverflow.ellipsis),
const SizedBox(height: 6), const SizedBox(height: 6),
@@ -586,6 +673,7 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
: ProgressRing( : ProgressRing(
fraction: routine.partialCompletion, fraction: routine.partialCompletion,
fill: tone, fill: tone,
track: tone.withValues(alpha: 0.12),
), ),
], ],
), ),
@@ -610,98 +698,162 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(icon, size: 12, color: colorGrey2), Icon(icon, size: 12, color: colorInkSoft),
const SizedBox(width: 4), const SizedBox(width: 4),
text(value, 11, TextType.Regular, color: colorGrey2), text(value, 11, TextType.Regular, color: colorInkSoft),
], ],
); );
} }
/// The preview: the whole chain laid out before you commit to starting it. /// The preview: the scene up top, then the whole chain laid out before you
/// commit to starting it.
Widget _previewSheet(BuildContext sheetContext, RoutineChain routine) { Widget _previewSheet(BuildContext sheetContext, RoutineChain routine) {
final Color tone = kindColor(routine.kind); final Color tone = kindColor(routine.kind);
return Container( return Container(
constraints: BoxConstraints( constraints: BoxConstraints(
maxHeight: MediaQuery.of(sheetContext).size.height * 0.86, maxHeight: MediaQuery.of(sheetContext).size.height * 0.9,
), ),
decoration: const BoxDecoration( decoration: const BoxDecoration(
color: colorSheetBackground, color: colorAmbientMid,
borderRadius: BorderRadius.only( borderRadius: BorderRadius.only(
topLeft: Radius.circular(28), topLeft: Radius.circular(32),
topRight: Radius.circular(28), topRight: Radius.circular(32),
), ),
), ),
clipBehavior: Clip.antiAlias,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Center( Flexible(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 220,
child: Stack(
fit: StackFit.expand,
children: [
SceneArt(
palette: scenePalette(routine.kind),
seed: sceneSeed(routine.title),
figure: kindIcon(routine.kind),
figureScale: 0.5,
figureAlignment: const Alignment(0.4, -0.1),
shade: true,
),
Positioned(
top: 10,
left: 0,
right: 0,
child: Center(
child: Container( child: Container(
width: 38, width: 38,
height: 4, height: 4,
margin: const EdgeInsets.only(top: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorGrey.withValues(alpha: 0.5), color: colorWhite.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
), ),
), ),
), ),
Flexible( ),
child: SingleChildScrollView( Positioned(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 12), left: 24,
right: 24,
bottom: 18,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Row( Row(
children: [ children: [
pill(kindLabel(routine.kind), tone, GlassSurface(
kindBackground(routine.kind), onDark: true,
icon: kindIcon(routine.kind), textSize: 9), radius: BorderRadius.circular(99),
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 5),
child: text(kindLabel(routine.kind), 10,
TextType.Medium,
color: colorWhite),
),
const SizedBox(width: 6), const SizedBox(width: 6),
pill(classLabel(routine.commitmentClass), GlassSurface(
classColor(routine.commitmentClass), onDark: true,
classBackground(routine.commitmentClass), radius: BorderRadius.circular(99),
textSize: 9), padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 5),
child: text(
classLabel(routine.commitmentClass),
10,
TextType.Medium,
color: colorWhite),
),
], ],
), ),
const SizedBox(height: 14), const SizedBox(height: 10),
displayTitle(routine.title, size: 30), text(routine.title, 28, TextType.Medium,
if (routine.note.isNotEmpty) ...[ color: colorWhite,
const SizedBox(height: 8), height: 1.1,
text(routine.note, 13, TextType.Regular, letterSpacing: -0.6),
color: colorGrey2, height: 1.5),
], ],
const SizedBox(height: 20), ),
Row( ),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 18, 24, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (routine.note.isNotEmpty) ...[
text(routine.note, 13, TextType.Regular,
color: colorInkSoft, height: 1.5),
const SizedBox(height: 16),
],
GlassSurface(
radius: BorderRadius.circular(22),
tint: colorGlassStrong,
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Expanded(
child: statTile("Minutes", child: _stripStat(
"${(routine.totalSeconds / 60).round()}")), CupertinoIcons.clock,
"${(routine.totalSeconds / 60).round()}",
"Minutes"),
),
Expanded( Expanded(
child: statTile("Steps", "${routine.steps.length}")), child: _stripStat(CupertinoIcons.list_bullet,
"${routine.steps.length}", "Steps"),
),
Expanded( Expanded(
child: statTile( child: _stripStat(
"When", CupertinoIcons.alarm,
routine.startMinute < 0 routine.startMinute < 0
? "Any" ? "Any"
: formatStartMinute(routine.startMinute), : formatStartMinute(
caption: formatDays(routine.days), routine.startMinute),
), formatDays(routine.days)),
), ),
], ],
), ),
const SizedBox(height: 24), ),
const SizedBox(height: 22),
sectionBreak("The chain", sectionBreak("The chain",
caption: routine.kind == RoutineKind.Workout caption: routine.kind == RoutineKind.Workout
? "${routine.totalSets} sets, in order" ? "${routine.totalSets} sets, in order"
: "${routine.steps.length} steps, in order"), : "${routine.steps.length} steps, in order"),
...routine.steps ...routine.steps.asMap().entries.map((entry) =>
.asMap() _previewStep(entry.key, entry.value, tone)),
.entries ],
.map((entry) => _previewStep(entry.key, entry.value, tone)), ),
),
], ],
), ),
), ),
@@ -738,20 +890,24 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
} }
Widget _previewStep(int index, RoutineStep step, Color tone) { Widget _previewStep(int index, RoutineStep step, Color tone) {
return Padding( return Container(
padding: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.fromLTRB(10, 10, 14, 10),
decoration: BoxDecoration(
color: colorWhite,
borderRadius: BorderRadius.circular(18),
),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( Container(
width: 28, width: 34,
height: 28, height: 34,
alignment: Alignment.center, alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(
color: tone.withValues(alpha: 0.10), color: tone.withValues(alpha: 0.10),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: text("${index + 1}", 11, TextType.Bold, color: tone), child: text("${index + 1}", 12, TextType.Medium, color: tone),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
@@ -759,18 +915,14 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const SizedBox(height: 4), text(step.title, 14, TextType.Medium, color: colorInk),
text(step.title, 14, TextType.Medium, color: colorPrimaryDark),
const SizedBox(height: 3), const SizedBox(height: 3),
text(stepSummary(step), 11, TextType.Regular, text(stepSummary(step), 11, TextType.Regular,
color: colorGrey2), color: colorInkSoft),
], ],
), ),
), ),
Padding( Icon(stepModeIcon(step.mode), size: 15, color: colorInkSoft),
padding: const EdgeInsets.only(top: 5),
child: Icon(stepModeIcon(step.mode), size: 15, color: colorGrey),
),
], ],
), ),
); );

View File

@@ -1,16 +1,20 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:stacked/stacked.dart'; import 'package:stacked/stacked.dart';
import '../../about/internal/application/MeDescription.dart'; import '../../about/internal/application/MeDescription.dart';
import '../../about/internal/application/NavigatorType.dart'; import '../../about/internal/application/NavigatorType.dart';
import '../../about/internal/application/TextType.dart'; import '../../about/internal/application/TextType.dart';
import '../../configs/Navigator.dart'; import '../../configs/Navigator.dart';
import '../../designs/AmbientBackground.dart';
import '../../designs/GlassSurface.dart';
import '../../designs/GroundedMark.dart';
import '../../designs/Responsive.dart'; import '../../designs/Responsive.dart';
import '../../designs/scene/SceneArt.dart';
import '../../designs/text/Text.dart'; import '../../designs/text/Text.dart';
import '../../utils/Colors.dart'; import '../../utils/Colors.dart';
import '../../utils/Images.dart';
import '../home/Home.dart'; import '../home/Home.dart';
import '../login/Login.dart'; import '../login/Login.dart';
import 'ConnectSplash.dart'; import 'ConnectSplash.dart';
@@ -31,7 +35,7 @@ class SplashState extends State<Splash> implements ConnectSplash {
builder: (context, viewModel, child) => PopScope( builder: (context, viewModel, child) => PopScope(
canPop: false, canPop: false,
child: Scaffold( child: Scaffold(
backgroundColor: colorPrimaryDark, backgroundColor: colorAmbientMid,
body: LayoutBuilder( body: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) { builder: (BuildContext context, BoxConstraints constraints) {
return Responsive( return Responsive(
@@ -47,69 +51,193 @@ class SplashState extends State<Splash> implements ConnectSplash {
} }
Future<void> _initiate() async { Future<void> _initiate() async {
// A beat on the wordmark, then the session decides where we land. // Long enough for the mark to land and take one breath, then the session
await Future.delayed(const Duration(milliseconds: 900)); // decides where we land.
await Future.delayed(const Duration(milliseconds: 2200));
_model?.initialize(); _model?.initialize();
} }
Widget _mobileView(BoxConstraints constraints) { Widget _mobileView(BoxConstraints constraints) {
return SafeArea( 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>[Color(0x00000000), Color(0xFF000000)],
stops: <double>[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( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 36), padding: const EdgeInsets.fromLTRB(28, 18, 28, 32),
child: Column( child: Column(
mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.stretch,
crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
text("GROUNDED", 10, TextType.Bold, text("Grounded", 17, TextType.Regular, color: colorInk),
color: colorWhite.withValues(alpha: 0.45), letterSpacing: 2.0), text("Daily discipline", 17, TextType.Regular,
Column( color: colorInkSoft),
mainAxisSize: MainAxisSize.min, ],
crossAxisAlignment: CrossAxisAlignment.start, ).animate().fadeIn(duration: 600.ms, delay: 200.ms),
children: [ const Spacer(flex: 3),
// The same mark the native splash shows, so the handover from Center(child: _mark()),
// the OS screen into the app is invisible. const SizedBox(height: 40),
Image.asset( _wordmark(),
splashMark, const Spacer(flex: 4),
width: 108, _loader(),
height: 108,
errorBuilder: (context, error, stackTrace) =>
const SizedBox(height: 108),
),
const SizedBox(height: 28),
text("Grounded", 52, TextType.Light,
color: colorWhite, height: 1.05),
const SizedBox(height: 16),
Container(
width: 44,
height: 2,
color: colorWhite.withValues(alpha: 0.30),
),
const SizedBox(height: 20),
text(
"A to-do app that does not believe you.",
15,
TextType.Regular,
color: colorWhite.withValues(alpha: 0.60),
height: 1.5,
),
], ],
), ),
SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
backgroundColor: colorWhite.withValues(alpha: 0.12),
color: colorWhite.withValues(alpha: 0.70),
), ),
), ),
], ],
), ),
),
); );
} }
/// The mark on glass, inside rings that breathe outward.
Widget _mark() {
return SizedBox(
width: 230,
height: 230,
child: Stack(
alignment: Alignment.center,
children: [
for (int ring = 0; ring < 3; ring++)
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: colorWhite.withValues(alpha: 0.9), 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,
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>[
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<double>(
tween: Tween<double>(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),
],
),
);
}
/// Two-tone, like the greeting on the home screen.
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),
const SizedBox(height: 10),
text("A to-do app that\ndoesn't believe you.", 22, TextType.Light,
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),
],
);
}
/// A glass capsule that fills while the session resolves.
Widget _loader() {
return Column(
children: [
GlassSurface(
radius: BorderRadius.circular(99),
padding: const EdgeInsets.all(3),
child: SizedBox(
width: 140,
height: 6,
child: Align(
alignment: Alignment.centerLeft,
child: Container(
width: 140,
decoration: BoxDecoration(
color: colorNight,
borderRadius: BorderRadius.circular(99),
),
)
.animate()
.scaleX(
begin: 0.04,
end: 1,
alignment: Alignment.centerLeft,
duration: 2000.ms,
curve: Curves.easeInOutCubic,
),
),
),
),
const SizedBox(height: 12),
text("Checking what you promised", 12, TextType.Regular,
color: colorWhite.withValues(alpha: 0.9)),
],
).animate().fadeIn(duration: 500.ms, delay: 900.ms);
}
@override @override
void launchHome(MeDescription value) { void launchHome(MeDescription value) {
GroundedNavigation() GroundedNavigation()

View File

@@ -102,6 +102,47 @@ const List<Color> goalAccents = <Color>[
Color(0xFF3F6E73), Color(0xFF3F6E73),
]; ];
// ── Ambient ───────────────────────────────────────────────────────────────────
// The light, airy surface everything now sits on: sage mist drifting into
// cream, frosted glass for controls, ink for type and one lime spark for the
// primary transport control.
const Color colorAmbientTop = Color(0xFFD5E3DA);
const Color colorAmbientMid = Color(0xFFEAEFE6);
const Color colorAmbientBottom = Color(0xFFF5F1E6);
const Color colorAmbientGlowSage = Color(0xFFB9D6C4);
const Color colorAmbientGlowWarm = Color(0xFFF3DEC0);
const Color colorInk = Color(0xFF1A1F1C);
const Color colorInkSoft = Color(0xFF8E9690);
const Color colorGlass = Color(0x8CFFFFFF);
const Color colorGlassStrong = Color(0xD9FFFFFF);
const Color colorGlassBorder = Color(0xB3FFFFFF);
const Color colorLime = Color(0xFFB9F25B);
const Color colorNight = Color(0xFF18211C);
const Color colorFrost = Color(0xE6F6F7F2);
// Night lit from the top-left: the tile, the insight card, the action button.
const Color colorNightLift = Color(0xFF2B3A31);
const Color colorNightGlow = Color(0xFF3A4A40);
// The overdue callout: a warm blush rather than an alarm panel.
const Color colorOverdueWarm = Color(0xFFF9E1D8);
const Color colorOverdueWarmEnd = Color(0xFFFFF6EF);
const Color colorOverdueGlow = Color(0xFFD7654B);
// ── Scene art ─────────────────────────────────────────────────────────────────
// The painted landscapes that stand in for photography on cards. Each kind has
// a sky (top → horizon), a sun, three ridges (far → near) and an ember accent.
const List<Color> sceneWorkout = <Color>[
Color(0xFF9CCBB0), Color(0xFFEFF1DC), Color(0xFFFFD49A),
Color(0xFF86B79B), Color(0xFF4E8C6F), Color(0xFF24563F), Color(0xFFE5793D),
];
const List<Color> sceneMeditation = <Color>[
Color(0xFFAEB8E6), Color(0xFFF6E3E2), Color(0xFFFFE6C6),
Color(0xFFA2A9D6), Color(0xFF7480B8), Color(0xFF3F4A7A), Color(0xFFF2A6A0),
];
const List<Color> sceneMustDo = <Color>[
Color(0xFFEBCFA3), Color(0xFFF8F0E0), Color(0xFFFFB46B),
Color(0xFFD9B37F), Color(0xFFB0834A), Color(0xFF6B4C26), Color(0xFF2F6F4E),
];
// ── Debt / charting ─────────────────────────────────────────────────────────── // ── Debt / charting ───────────────────────────────────────────────────────────
const Color colorDebtLine = Color(0xFFB5442F); const Color colorDebtLine = Color(0xFFB5442F);
const Color colorDebtFill = Color(0x1AB5442F); const Color colorDebtFill = Color(0x1AB5442F);

View File

@@ -352,3 +352,55 @@ String targetLabel(DateTime? target) {
} }
return "$days day${days == 1 ? "" : "s"} left"; return "$days day${days == 1 ? "" : "s"} left";
} }
/// The painted landscape a routine kind wears on its cards.
List<Color> scenePalette(RoutineKind value) {
switch (value) {
case RoutineKind.Workout:
return sceneWorkout;
case RoutineKind.Meditation:
return sceneMeditation;
case RoutineKind.MustDo:
return sceneMustDo;
}
}
/// A landscape tinted from a goal's accent, so a goal's cards read as its own.
List<Color> goalScene(Color accent) {
final HSLColor base = HSLColor.fromColor(accent);
Color shade(double lightness, double saturation) => base
.withLightness(lightness)
.withSaturation((base.saturation * saturation).clamp(0.0, 1.0))
.toColor();
return <Color>[
shade(0.80, 0.55),
const Color(0xFFF6EFE2),
const Color(0xFFFFD9A0),
shade(0.66, 0.6),
shade(0.48, 0.8),
shade(0.30, 0.9),
const Color(0xFFE5793D),
];
}
/// The landscape for a task row, chosen by how much the task weighs.
List<Color> classScene(CommitmentClass value) {
switch (value) {
case CommitmentClass.NonNegotiable:
return sceneMustDo;
case CommitmentClass.Standard:
return sceneMeditation;
case CommitmentClass.Elective:
return sceneWorkout;
}
}
/// A stable seed from any string, so the same item always gets the same scene.
int sceneSeed(String key) {
int hash = 7;
for (int unit in key.codeUnits) {
hash = (hash * 31 + unit) & 0x7fffffff;
}
return hash;
}

View File

@@ -2,8 +2,8 @@ const String imagePath = "assets/images";
const String iconPath = "assets/icons"; const String iconPath = "assets/icons";
/// The anchor mark, matching the native splash so the handover is invisible. /// The mark on a night disc, as the native splash shows it.
const String splashMark = "$iconPath/splash.png"; const String splashMark = "$iconPath/splash_orb.png";
const String logoMark = "$iconPath/icon.png"; const String logoMark = "$iconPath/icon.png";

View File

@@ -104,15 +104,15 @@ flutter:
flutter_launcher_icons: flutter_launcher_icons:
image_path: "assets/icons/icon.png" image_path: "assets/icons/icon.png"
android: "ic_launcher" android: "ic_launcher"
adaptive_icon_background: "#141414" adaptive_icon_background: "#18211C"
adaptive_icon_foreground: "assets/icons/icon_foreground.png" adaptive_icon_foreground: "assets/icons/icon_foreground.png"
min_sdk_android: 21 min_sdk_android: 21
ios: true ios: true
remove_alpha_ios: true remove_alpha_ios: true
web: web:
generate: true generate: true
background_color: "#141414" background_color: "#EAEFE6"
theme_color: "#141414" theme_color: "#18211C"
windows: windows:
generate: true generate: true
icon_size: 256 icon_size: 256
@@ -121,12 +121,12 @@ flutter_launcher_icons:
# The native splash matches the Flutter splash exactly, so the handover from # The native splash matches the Flutter splash exactly, so the handover from
# the OS screen to the app is invisible. # the OS screen to the app is invisible.
flutter_native_splash: flutter_native_splash:
color: "#141414" color: "#EAEFE6"
image: "assets/icons/splash.png" image: "assets/icons/splash_orb.png"
android_12: android_12:
color: "#141414" color: "#EAEFE6"
image: "assets/icons/icon_foreground.png" image: "assets/icons/splash_orb.png"
icon_background_color: "#141414" icon_background_color: "#EAEFE6"
android: true android: true
ios: true ios: true
web: true web: true

Binary file not shown.

Before

Width:  |  Height:  |  Size: 390 B

After

Width:  |  Height:  |  Size: 388 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -29,6 +29,11 @@
<title>grounded</title> <title>grounded</title>
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport">
<style id="splash-screen-style"> <style id="splash-screen-style">
html { html {
height: 100% height: 100%
@@ -37,7 +42,7 @@
body { body {
margin: 0; margin: 0;
min-height: 100%; min-height: 100%;
background-color: #141414; background-color: #EAEFE6;
background-size: 100% 100%; background-size: 100% 100%;
} }
@@ -94,7 +99,6 @@
document.body.style.background = "transparent"; document.body.style.background = "transparent";
} }
</script> </script>
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport">
</head> </head>
<body> <body>
<picture id="splash"> <picture id="splash">
@@ -102,6 +106,8 @@
<source srcset="splash/img/dark-1x.png 1x, splash/img/dark-2x.png 2x, splash/img/dark-3x.png 3x, splash/img/dark-4x.png 4x" media="(prefers-color-scheme: dark)"> <source srcset="splash/img/dark-1x.png 1x, splash/img/dark-2x.png 2x, splash/img/dark-3x.png 3x, splash/img/dark-4x.png 4x" media="(prefers-color-scheme: dark)">
<img class="center" aria-hidden="true" src="splash/img/light-1x.png" alt=""> <img class="center" aria-hidden="true" src="splash/img/light-1x.png" alt="">
</picture> </picture>
<!-- <!--
You can customize the "flutter_bootstrap.js" script. You can customize the "flutter_bootstrap.js" script.
This is useful to provide a custom configuration to the Flutter loader This is useful to provide a custom configuration to the Flutter loader

View File

@@ -3,8 +3,8 @@
"short_name": "grounded", "short_name": "grounded",
"start_url": ".", "start_url": ".",
"display": "standalone", "display": "standalone",
"background_color": "#141414", "background_color": "#EAEFE6",
"theme_color": "#141414", "theme_color": "#18211C",
"description": "A new Flutter project.", "description": "A new Flutter project.",
"orientation": "portrait-primary", "orientation": "portrait-primary",
"prefer_related_applications": false, "prefer_related_applications": false,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB