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,158 +51,207 @@ 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, bottom: false,
body: Column( child: Padding(
mainAxisSize: MainAxisSize.max, padding: const EdgeInsets.fromLTRB(16, 6, 16, 18),
crossAxisAlignment: CrossAxisAlignment.stretch, child: Column(
children: [ mainAxisSize: MainAxisSize.min,
SafeArea( children: [
bottom: false, Row(
child: Padding( children: [
padding: const EdgeInsets.fromLTRB(12, 4, 12, 16), SizedBox(
child: Column( width: 46,
mainAxisSize: MainAxisSize.min, child: onBack == null
children: [ ? null
Row( : Align(
children: [ alignment: Alignment.centerLeft,
SizedBox( child: glassCircle(
width: 44, CupertinoIcons.arrow_left, onBack!,
child: onBack == null onDark: !light),
? 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,
),
],
), ),
), ),
SizedBox( Expanded(
width: 44, child: Column(
child: action == null mainAxisSize: MainAxisSize.min,
? null children: [
: Align( if (eyebrow.isNotEmpty) ...[
alignment: Alignment.centerRight, text(
child: action, 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),
), ),
), SizedBox(
clipBehavior: Clip.antiAlias, width: 46,
child: scrollable child: action == null
? SingleChildScrollView( ? null
padding: const EdgeInsets.fromLTRB(20, 28, 20, 40), : Align(
child: child, alignment: Alignment.centerRight,
) child: action,
: Padding( ),
padding: const EdgeInsets.fromLTRB(20, 28, 20, 0), ),
child: child, ],
),
), ),
), if (banner != null) ...[
], const SizedBox(height: 18),
banner!,
],
],
),
), ),
); );
}
Widget _chromeButton(IconData icon, VoidCallback onTap) { final Widget body = Expanded(
return GestureDetector(
onTap: onTap,
child: Container( child: Container(
width: 38,
height: 38,
alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorWhite.withValues(alpha: 0.10), color: light ? colorFrost : colorPrimaryLight,
shape: BoxShape.circle, 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( 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,41 +306,40 @@ 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: [ mainAxisAlignment: MainAxisAlignment.spaceBetween,
hairline(margin: const EdgeInsets.only(bottom: 20)), crossAxisAlignment: CrossAxisAlignment.center,
Row( children: [
mainAxisAlignment: MainAxisAlignment.spaceBetween, Expanded(
crossAxisAlignment: CrossAxisAlignment.center, child: Row(
children: [ crossAxisAlignment: CrossAxisAlignment.end,
Expanded( children: [
child: Row( text(heading, 16, TextType.Medium, color: colorInk),
crossAxisAlignment: CrossAxisAlignment.end, if (caption.isNotEmpty) ...[
children: [ const SizedBox(width: 8),
text(heading, 17, TextType.Bold, color: colorPrimaryDark), Flexible(
if (caption.isNotEmpty) ...[ child: Padding(
const SizedBox(width: 10), padding: const EdgeInsets.only(bottom: 1),
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: text(caption, 11, TextType.Regular, child: text(caption, 11, TextType.Regular,
color: colorGrey2), color: colorInkSoft,
maxLines: 1,
overflow: TextOverflow.ellipsis),
), ),
], ),
], ],
), ],
), ),
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,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) { 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), onTap: () => _onOpenGoal(goal),
child: card( child: Container(
onTap: () => _onOpenGoal(goal), height: 214,
child: Column( margin: const EdgeInsets.only(bottom: 14),
crossAxisAlignment: CrossAxisAlignment.start, decoration: BoxDecoration(
mainAxisSize: MainAxisSize.min, borderRadius: BorderRadius.circular(26),
children: [ boxShadow: const [
Row( BoxShadow(
crossAxisAlignment: CrossAxisAlignment.start, color: Color(0x262A3A30),
children: [ blurRadius: 24,
Expanded( offset: Offset(0, 10),
child: Column( ),
crossAxisAlignment: CrossAxisAlignment.start, ],
mainAxisSize: MainAxisSize.min, ),
children: [ child: ClipRRect(
Row( borderRadius: BorderRadius.circular(26),
children: [ child: Stack(
Container( fit: StackFit.expand,
width: 8, children: [
height: 8, SceneArt(
decoration: BoxDecoration( palette: goalScene(accent),
color: goalColor(goal.colourHex), seed: sceneSeed(goal.title),
shape: BoxShape.circle, figure: CupertinoIcons.flag_fill,
), figureScale: 0.4,
), figureAlignment: const Alignment(0.75, -0.35),
const SizedBox(width: 8), shade: true,
text( ),
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(),
const SizedBox(height: 8), if (goal.overdueTasks > 0)
text(goal.title, 19, TextType.Light, pill("${goal.overdueTasks} late", colorWhite,
color: colorPrimaryDark, colorStandingGrounded,
maxLines: 2, textSize: 9),
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(),
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 @override
void onGoalsLoaded(List<Goal> goals) { void onGoalsLoaded(List<Goal> goals) {
setState(() { setState(() {

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(

File diff suppressed because it is too large Load Diff

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();

File diff suppressed because it is too large Load Diff

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: Padding( child: Stack(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 36), children: [
child: Column( // The landscape rising from the bottom edge — the same painted world
mainAxisSize: MainAxisSize.max, // the routine cards live in.
crossAxisAlignment: CrossAxisAlignment.start, Positioned(
mainAxisAlignment: MainAxisAlignment.spaceBetween, left: 0,
children: [ right: 0,
text("GROUNDED", 10, TextType.Bold, bottom: 0,
color: colorWhite.withValues(alpha: 0.45), letterSpacing: 2.0), height: constraints.maxHeight * 0.34,
Column( child: ShaderMask(
mainAxisSize: MainAxisSize.min, shaderCallback: (Rect bounds) => const LinearGradient(
crossAxisAlignment: CrossAxisAlignment.start, begin: Alignment.topCenter,
children: [ end: Alignment.bottomCenter,
// The same mark the native splash shows, so the handover from colors: <Color>[Color(0x00000000), Color(0xFF000000)],
// the OS screen into the app is invisible. stops: <double>[0.0, 0.45],
Image.asset( ).createShader(bounds),
splashMark, blendMode: BlendMode.dstIn,
width: 108, child: const SceneArt(palette: sceneWorkout, seed: 11),
height: 108, )
errorBuilder: (context, error, stackTrace) => .animate()
const SizedBox(height: 108), .fadeIn(duration: 1200.ms, curve: Curves.easeOut)
), .slideY(begin: 0.25, end: 0, duration: 1400.ms,
const SizedBox(height: 28), curve: Curves.easeOutCubic),
text("Grounded", 52, TextType.Light, ),
color: colorWhite, height: 1.05), SafeArea(
const SizedBox(height: 16), child: Padding(
Container( padding: const EdgeInsets.fromLTRB(28, 18, 28, 32),
width: 44, child: Column(
height: 2, crossAxisAlignment: CrossAxisAlignment.stretch,
color: colorWhite.withValues(alpha: 0.30), children: [
), Row(
const SizedBox(height: 20), mainAxisAlignment: MainAxisAlignment.spaceBetween,
text( children: [
"A to-do app that does not believe you.", text("Grounded", 17, TextType.Regular, color: colorInk),
15, text("Daily discipline", 17, TextType.Regular,
TextType.Regular, color: colorInkSoft),
color: colorWhite.withValues(alpha: 0.60), ],
height: 1.5, ).animate().fadeIn(duration: 600.ms, delay: 200.ms),
), const Spacer(flex: 3),
], Center(child: _mark()),
), const SizedBox(height: 40),
SizedBox( _wordmark(),
width: 22, const Spacer(flex: 4),
height: 22, _loader(),
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