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
See `design.md`. In short: black chrome → white sheet with a 28 radius →
oversized **Light** display title → tiny grey all-caps labels above values.
See `design.md`. In short: sage-to-cream ambient backdrop, frosted glass
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.
- Never raw `Text`/`TextStyle` — use `text()` from `designs/text/Text.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:windowDrawsSystemBarBackgrounds">false</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:windowSplashScreenIconBackgroundColor">#141414</item>
<item name="android:windowSplashScreenIconBackgroundColor">#EAEFE6</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
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:windowDrawsSystemBarBackgrounds">false</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:windowSplashScreenIconBackgroundColor">#141414</item>
<item name="android:windowSplashScreenIconBackgroundColor">#EAEFE6</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#141414</color>
<color name="ic_launcher_background">#18211C</color>
</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
Reference: black device chrome, a white content sheet that rises into it with a
large corner radius, oversized light display titles, tiny grey all-caps metadata
labels, pill chips, and a lot of whitespace. The app reads as a record, not a
dashboard.
Reference: an airy fitness-app look. Sage mist drifting into cream behind
everything (`AmbientBackground`), frosted glass for controls and floating
chrome (`GlassSurface`, `glassCircle`), ink type with a soft grey second
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

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,
Color? background,
Color? borderColor,
double radius = 16,
double radius = 22,
VoidCallback? onTap,
}) {
final Widget body = Container(
@@ -71,12 +71,12 @@ Widget card({
decoration: BoxDecoration(
color: background ?? colorCard,
borderRadius: BorderRadius.circular(radius),
border: Border.all(color: borderColor ?? colorBorder, width: 1),
border: Border.all(color: borderColor ?? colorGlassBorder, width: 1),
boxShadow: const [
BoxShadow(
color: Color(0x08000000),
blurRadius: 18,
offset: Offset(0, 4),
color: Color(0x0F2A3A30),
blurRadius: 24,
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 '../about/internal/application/TextType.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';
/// The house layout: black chrome at the top, a white sheet rising into it with
/// a large corner radius. Every screen is built from this so the app reads as
/// one object rather than a stack of pages.
/// The house layout. On the default ambient tone the screen floats on sage
/// mist with frosted controls and ink type, and the content rises on a frosted
/// 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 {
/// 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;
/// The chrome title — small and centred, not the display title.
@@ -22,7 +27,7 @@ class Sheet extends StatelessWidget {
/// Optional trailing control in the chrome.
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;
/// Rendered inside the chrome beneath the title — the standing strip.
@@ -30,6 +35,12 @@ class Sheet extends StatelessWidget {
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({
super.key,
required this.title,
@@ -40,158 +51,207 @@ class Sheet extends StatelessWidget {
this.chrome,
this.banner,
this.scrollable = true,
this.scene,
this.sceneSeed = 0,
});
bool get _light => chrome == null && banner == null && scene == null;
@override
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(
backgroundColor: chromeColor,
body: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
SizedBox(
width: 44,
child: onBack == null
? null
: _chromeButton(
Icons.arrow_back_ios_new_rounded, onBack!),
),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (eyebrow.isNotEmpty) ...[
text(
eyebrow.toUpperCase(),
9,
TextType.Bold,
color: colorWhite.withValues(alpha: 0.45),
letterSpacing: 1.2,
align: TextAlign.center,
),
const SizedBox(height: 3),
],
text(
title,
15,
TextType.Medium,
color: colorWhite,
align: TextAlign.center,
),
],
final Widget header = SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 18),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
SizedBox(
width: 46,
child: onBack == null
? null
: Align(
alignment: Alignment.centerLeft,
child: glassCircle(
CupertinoIcons.arrow_left, onBack!,
onDark: !light),
),
),
SizedBox(
width: 44,
child: action == null
? null
: Align(
alignment: Alignment.centerRight,
child: action,
),
),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (eyebrow.isNotEmpty) ...[
text(
eyebrow.toUpperCase(),
9,
TextType.Bold,
color: soft,
letterSpacing: 1.2,
align: TextAlign.center,
),
const SizedBox(height: 3),
],
text(
title.toUpperCase(),
14,
TextType.Medium,
color: ink,
letterSpacing: 0.6,
align: TextAlign.center,
),
],
),
if (banner != null) ...[
const SizedBox(height: 16),
banner!,
],
],
),
),
),
Expanded(
child: Container(
decoration: BoxDecoration(
color: colorPrimaryLight,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(28),
topRight: Radius.circular(28),
),
),
clipBehavior: Clip.antiAlias,
child: scrollable
? SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 28, 20, 40),
child: child,
)
: Padding(
padding: const EdgeInsets.fromLTRB(20, 28, 20, 0),
child: child,
),
SizedBox(
width: 46,
child: action == null
? null
: Align(
alignment: Alignment.centerRight,
child: action,
),
),
],
),
),
],
if (banner != null) ...[
const SizedBox(height: 18),
banner!,
],
],
),
),
);
}
Widget _chromeButton(IconData icon, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
final Widget body = Expanded(
child: Container(
width: 38,
height: 38,
alignment: Alignment.center,
decoration: BoxDecoration(
color: colorWhite.withValues(alpha: 0.10),
shape: BoxShape.circle,
color: light ? colorFrost : colorPrimaryLight,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(32),
topRight: Radius.circular(32),
),
),
child: Icon(icon, size: 15, color: colorWhite),
clipBehavior: Clip.antiAlias,
child: scrollable
? SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 28, 20, 40),
child: child,
)
: Padding(
padding: const EdgeInsets.fromLTRB(20, 28, 20, 0),
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),
],
),
);
}
}
/// 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(
IconData icon,
VoidCallback onTap, {
bool dotted = false,
Color? dotColor,
}) {
return GestureDetector(
onTap: onTap,
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 38,
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),
),
),
),
],
return Builder(
builder: (BuildContext context) => glassCircle(
icon,
onTap,
onDark: !ChromeTone.isLight(context),
dotted: dotted,
dotColor: dotColor,
),
);
}
@@ -215,12 +275,11 @@ Widget labelled(
label.toUpperCase(),
9,
TextType.Bold,
color: labelColor ?? colorGrey2,
color: labelColor ?? colorInkSoft,
letterSpacing: 1.0,
),
const SizedBox(height: 5),
text(value, valueSize, valueType,
color: valueColor ?? colorPrimaryDark),
text(value, valueSize, valueType, color: valueColor ?? colorInk),
],
);
}
@@ -247,41 +306,40 @@ Widget metaRow(List<Widget> items) {
/// The oversized light display title that opens a section.
Widget displayTitle(String value, {Color? color, double size = 34}) {
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
/// trailing chip.
/// A section heading: medium ink label, a soft caption beside it and an
/// optional trailing control ("View all").
Widget sectionBreak(String heading, {Widget? trailing, String caption = ""}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
hairline(margin: const EdgeInsets.only(bottom: 20)),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
text(heading, 17, TextType.Bold, color: colorPrimaryDark),
if (caption.isNotEmpty) ...[
const SizedBox(width: 10),
Padding(
padding: const EdgeInsets.only(bottom: 2),
return Padding(
padding: const EdgeInsets.only(top: 6, bottom: 14),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
text(heading, 16, TextType.Medium, color: colorInk),
if (caption.isNotEmpty) ...[
const SizedBox(width: 8),
Flexible(
child: Padding(
padding: const EdgeInsets.only(bottom: 1),
child: text(caption, 11, TextType.Regular,
color: colorGrey2),
color: colorInkSoft,
maxLines: 1,
overflow: TextOverflow.ellipsis),
),
],
),
],
),
],
),
if (trailing != null) trailing,
],
),
const SizedBox(height: 16),
],
),
if (trailing != null) trailing,
],
),
);
}

View File

@@ -13,10 +13,10 @@ Widget roundedCornerButton(
Color? foreground,
IconData? icon,
bool enabled = true,
double radius = 14,
double radius = 99,
double verticalPadding = 16,
}) {
final Color bg = enabled ? (background ?? colorPrimaryDark) : colorGrey;
final Color bg = enabled ? (background ?? colorNight) : colorGrey;
final Color fg = foreground ?? colorWhite;
return ElevatedButton(
@@ -35,10 +35,13 @@ Widget roundedCornerButton(
mainAxisAlignment: MainAxisAlignment.center,
children: [
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),
],
text(label, 14, TextType.Bold, color: fg),
text(label, 14, TextType.Medium, color: fg),
],
),
);
@@ -52,7 +55,7 @@ Widget outlinedActionButton(
Color? foreground,
IconData? icon,
bool enabled = true,
double radius = 14,
double radius = 99,
}) {
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/Shell.dart';
import '../../designs/buttons/Buttons.dart';
import '../../designs/scene/SceneArt.dart';
import '../../designs/text/Text.dart';
import '../../utils/Colors.dart';
import '../../utils/CommonUtils.dart';
@@ -181,6 +182,8 @@ class GoalDetailState extends State<GoalDetail>
title: _goal.title,
onBack: _onBack,
chrome: _goal.colourHex.isEmpty ? null : goalColor(_goal.colourHex),
scene: goalScene(goalColor(_goal.colourHex)),
sceneSeed: sceneSeed(_goal.title),
action: chromeAction(CupertinoIcons.pencil, _onEditGoal),
banner: _progressBanner(),
child: Column(
@@ -398,15 +401,19 @@ class GoalDetailState extends State<GoalDetail>
onTap: () => _onEditRoutine(routine),
child: Row(
children: [
Container(
width: 48,
height: 48,
alignment: Alignment.center,
decoration: BoxDecoration(
color: kindBackground(routine.kind),
borderRadius: BorderRadius.circular(13),
SizedBox(
width: 58,
height: 58,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: SceneArt(
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),
Expanded(

View File

@@ -5,9 +5,11 @@ import '../../about/external/data/Goal.dart';
import '../../about/internal/application/TextType.dart';
import '../../configs/Navigator.dart';
import '../../designs/Component.dart';
import '../../designs/GlassSurface.dart';
import '../../designs/Responsive.dart';
import '../../designs/Shell.dart';
import '../../designs/buttons/Buttons.dart';
import '../../designs/scene/SceneArt.dart';
import '../../designs/text/Text.dart';
import '../../utils/Colors.dart';
import '../../utils/CommonUtils.dart';
@@ -104,112 +106,136 @@ 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) {
final Color accent =
goal.slipping ? colorStandingGrounded : goalColor(goal.colourHex);
final Color accent = goalColor(goal.colourHex);
final Color soft = colorWhite.withValues(alpha: 0.78);
final bool overdue = goal.targetDate != null &&
goal.targetDate!.isBefore(DateTime.now());
return Container(
margin: const EdgeInsets.only(bottom: 12),
child: card(
onTap: () => _onOpenGoal(goal),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
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(
color: goalColor(goal.colourHex),
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
text(
return GestureDetector(
onTap: () => _onOpenGoal(goal),
child: Container(
height: 214,
margin: const EdgeInsets.only(bottom: 14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(26),
boxShadow: const [
BoxShadow(
color: Color(0x262A3A30),
blurRadius: 24,
offset: Offset(0, 10),
),
],
),
child: ClipRRect(
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,
goal.targetDate == null
? "No deadline"
: targetLabel(goal.targetDate),
].join(" · ").toUpperCase(),
9,
TextType.Bold,
color: goal.targetDate != null &&
goal.targetDate!.isBefore(DateTime.now())
? colorStandingGrounded
: colorGrey2,
letterSpacing: 1.0,
].join(" · "),
10,
TextType.Medium,
color: overdue ? colorTinted : colorWhite,
),
],
),
const SizedBox(height: 8),
text(goal.title, 19, TextType.Light,
color: colorPrimaryDark,
maxLines: 2,
overflow: TextOverflow.ellipsis),
if (goal.description.isNotEmpty) ...[
const SizedBox(height: 6),
text(goal.description, 12, TextType.Regular,
color: colorGrey2,
maxLines: 2,
overflow: TextOverflow.ellipsis),
),
const Spacer(),
if (goal.overdueTasks > 0)
pill("${goal.overdueTasks} late", colorWhite,
colorStandingGrounded,
textSize: 9),
],
),
const Spacer(),
text(goal.title, 21, TextType.Medium,
color: colorWhite,
maxLines: 1,
overflow: TextOverflow.ellipsis),
if (goal.description.isNotEmpty) ...[
const SizedBox(height: 3),
text(goal.description, 12, TextType.Regular,
color: soft,
maxLines: 1,
overflow: TextOverflow.ellipsis),
],
),
const SizedBox(height: 12),
meter(goal.progress,
fill: colorLime,
track: colorWhite.withValues(alpha: 0.25),
height: 5),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: _cardStat(
"${goal.completedTasks}/${goal.totalTasks}",
"Done",
soft),
),
Expanded(
child: _cardStat(
"${goal.remainingTasks}", "Remaining", soft),
),
Expanded(
child: _cardStat(
formatDebt(goal.debtContribution), "Debt", soft,
alert: goal.debtContribution > 0),
),
],
),
],
),
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),
Row(
children: [
Expanded(
child: labelled(
"Done",
"${goal.completedTasks} of ${goal.totalTasks}",
valueSize: 13,
),
),
Expanded(
child: labelled(
"Remaining",
"${goal.remainingTasks}",
valueSize: 13,
),
),
Expanded(
child: labelled(
"Debt",
formatDebt(goal.debtContribution),
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),
),
],
);
}
@override
void onGoalsLoaded(List<Goal> goals) {
setState(() {

View File

@@ -1,5 +1,6 @@
import '../../about/external/data/Commitment.dart';
import '../../about/external/data/ExcuseCluster.dart';
import '../../about/external/data/RoutineChain.dart';
import '../../about/internal/application/Standing.dart';
import '../../about/internal/application/UserDetails.dart';
@@ -10,6 +11,9 @@ abstract class ConnectHome {
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.
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/ExcuseCluster.dart';
import '../../about/external/data/RoutineChain.dart';
import '../../about/external/data/pages/request/CommitmentsRequest.dart';
import '../../about/external/data/pages/request/HistoryRequest.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 '../../utils/DebtEngine.dart';
import '../../utils/GuardrailEngine.dart';
import '../../utils/ObjectConvertors.dart';
import '../../utils/StandingEngine.dart';
import '../../utils/ToneEngine.dart';
import '../parent/ParentViewModel.dart';
@@ -52,11 +54,30 @@ class ViewHome extends ParentViewModel {
connection.onPlanLoaded(page.content);
loadOverdue();
loadRoutines();
} catch (e) {
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 {
try {
final response = await getDataManager().getOverdueQueue(HistoryRequest(

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,13 @@
import 'package:flutter/material.dart';
import '../../about/internal/application/RoutineKind.dart';
import 'RoutinesState.dart';
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
State<Routines> createState() => RoutinesState();

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,20 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:stacked/stacked.dart';
import '../../about/internal/application/MeDescription.dart';
import '../../about/internal/application/NavigatorType.dart';
import '../../about/internal/application/TextType.dart';
import '../../configs/Navigator.dart';
import '../../designs/AmbientBackground.dart';
import '../../designs/GlassSurface.dart';
import '../../designs/GroundedMark.dart';
import '../../designs/Responsive.dart';
import '../../designs/scene/SceneArt.dart';
import '../../designs/text/Text.dart';
import '../../utils/Colors.dart';
import '../../utils/Images.dart';
import '../home/Home.dart';
import '../login/Login.dart';
import 'ConnectSplash.dart';
@@ -31,7 +35,7 @@ class SplashState extends State<Splash> implements ConnectSplash {
builder: (context, viewModel, child) => PopScope(
canPop: false,
child: Scaffold(
backgroundColor: colorPrimaryDark,
backgroundColor: colorAmbientMid,
body: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Responsive(
@@ -47,69 +51,193 @@ class SplashState extends State<Splash> implements ConnectSplash {
}
Future<void> _initiate() async {
// A beat on the wordmark, then the session decides where we land.
await Future.delayed(const Duration(milliseconds: 900));
// Long enough for the mark to land and take one breath, then the session
// decides where we land.
await Future.delayed(const Duration(milliseconds: 2200));
_model?.initialize();
}
Widget _mobileView(BoxConstraints constraints) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 36),
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
text("GROUNDED", 10, TextType.Bold,
color: colorWhite.withValues(alpha: 0.45), letterSpacing: 2.0),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// The same mark the native splash shows, so the handover from
// the OS screen into the app is invisible.
Image.asset(
splashMark,
width: 108,
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),
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(
padding: const EdgeInsets.fromLTRB(28, 18, 28, 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
text("Grounded", 17, TextType.Regular, color: colorInk),
text("Daily discipline", 17, TextType.Regular,
color: colorInkSoft),
],
).animate().fadeIn(duration: 600.ms, delay: 200.ms),
const Spacer(flex: 3),
Center(child: _mark()),
const SizedBox(height: 40),
_wordmark(),
const Spacer(flex: 4),
_loader(),
],
),
),
],
),
),
],
),
);
}
/// 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
void launchHome(MeDescription value) {
GroundedNavigation()

View File

@@ -102,6 +102,47 @@ const List<Color> goalAccents = <Color>[
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 ───────────────────────────────────────────────────────────
const Color colorDebtLine = Color(0xFFB5442F);
const Color colorDebtFill = Color(0x1AB5442F);

View File

@@ -352,3 +352,55 @@ String targetLabel(DateTime? target) {
}
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";
/// The anchor mark, matching the native splash so the handover is invisible.
const String splashMark = "$iconPath/splash.png";
/// The mark on a night disc, as the native splash shows it.
const String splashMark = "$iconPath/splash_orb.png";
const String logoMark = "$iconPath/icon.png";

View File

@@ -104,15 +104,15 @@ flutter:
flutter_launcher_icons:
image_path: "assets/icons/icon.png"
android: "ic_launcher"
adaptive_icon_background: "#141414"
adaptive_icon_background: "#18211C"
adaptive_icon_foreground: "assets/icons/icon_foreground.png"
min_sdk_android: 21
ios: true
remove_alpha_ios: true
web:
generate: true
background_color: "#141414"
theme_color: "#141414"
background_color: "#EAEFE6"
theme_color: "#18211C"
windows:
generate: true
icon_size: 256
@@ -121,12 +121,12 @@ flutter_launcher_icons:
# The native splash matches the Flutter splash exactly, so the handover from
# the OS screen to the app is invisible.
flutter_native_splash:
color: "#141414"
image: "assets/icons/splash.png"
color: "#EAEFE6"
image: "assets/icons/splash_orb.png"
android_12:
color: "#141414"
image: "assets/icons/icon_foreground.png"
icon_background_color: "#141414"
color: "#EAEFE6"
image: "assets/icons/splash_orb.png"
icon_background_color: "#EAEFE6"
android: true
ios: 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>
<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">
html {
height: 100%
@@ -37,7 +42,7 @@
body {
margin: 0;
min-height: 100%;
background-color: #141414;
background-color: #EAEFE6;
background-size: 100% 100%;
}
@@ -94,7 +99,6 @@
document.body.style.background = "transparent";
}
</script>
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport">
</head>
<body>
<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)">
<img class="center" aria-hidden="true" src="splash/img/light-1x.png" alt="">
</picture>
<!--
You can customize the "flutter_bootstrap.js" script.
This is useful to provide a custom configuration to the Flutter loader

View File

@@ -3,8 +3,8 @@
"short_name": "grounded",
"start_url": ".",
"display": "standalone",
"background_color": "#141414",
"theme_color": "#141414",
"background_color": "#EAEFE6",
"theme_color": "#18211C",
"description": "A new Flutter project.",
"orientation": "portrait-primary",
"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