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
@@ -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`.
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 69 B |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 69 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 7.0 KiB |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 31 KiB |
BIN
frontend/assets/icons/splash_orb.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
@@ -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
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 473 B After Width: | Height: | Size: 492 B |
|
Before Width: | Height: | Size: 934 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 683 B After Width: | Height: | Size: 831 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 934 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 69 B |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 44 KiB |
61
frontend/lib/Grounded/designs/AmbientBackground.dart
Normal 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)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
frontend/lib/Grounded/designs/ChromeTone.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
175
frontend/lib/Grounded/designs/DotDigits.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
125
frontend/lib/Grounded/designs/FloatingNav.dart
Normal 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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
104
frontend/lib/Grounded/designs/GlassSurface.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
117
frontend/lib/Grounded/designs/GroundedMark.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../about/internal/application/TextType.dart';
|
import '../about/internal/application/TextType.dart';
|
||||||
import '../utils/Colors.dart';
|
import '../utils/Colors.dart';
|
||||||
import 'Component.dart';
|
import 'AmbientBackground.dart';
|
||||||
|
import 'ChromeTone.dart';
|
||||||
|
import 'GlassSurface.dart';
|
||||||
|
import 'scene/SceneArt.dart';
|
||||||
import 'text/Text.dart';
|
import 'text/Text.dart';
|
||||||
|
|
||||||
/// The house layout: black chrome at the top, a white sheet rising into it with
|
/// The house layout. On the default ambient tone the screen floats on sage
|
||||||
/// a large corner radius. Every screen is built from this so the app reads as
|
/// mist with frosted controls and ink type, and the content rises on a frosted
|
||||||
/// one object rather than a stack of pages.
|
/// sheet. Screens that carry a colour — a goal, a standing tier — or a banner
|
||||||
|
/// get a tinted stage instead, with white type and smoky glass.
|
||||||
class Sheet extends StatelessWidget {
|
class Sheet extends StatelessWidget {
|
||||||
/// Small all-caps label rendered in the black chrome, above the title.
|
/// Small all-caps label rendered in the chrome, above the title.
|
||||||
final String eyebrow;
|
final String eyebrow;
|
||||||
|
|
||||||
/// The chrome title — small and centred, not the display title.
|
/// The chrome title — small and centred, not the display title.
|
||||||
@@ -22,7 +27,7 @@ class Sheet extends StatelessWidget {
|
|||||||
/// Optional trailing control in the chrome.
|
/// Optional trailing control in the chrome.
|
||||||
final Widget? action;
|
final Widget? action;
|
||||||
|
|
||||||
/// Chrome colour. Defaults to near-black; standing screens tint it.
|
/// Stage colour. Leaving it null (with no banner) gives the ambient tone.
|
||||||
final Color? chrome;
|
final Color? chrome;
|
||||||
|
|
||||||
/// Rendered inside the chrome beneath the title — the standing strip.
|
/// Rendered inside the chrome beneath the title — the standing strip.
|
||||||
@@ -30,6 +35,12 @@ class Sheet extends StatelessWidget {
|
|||||||
|
|
||||||
final bool scrollable;
|
final bool scrollable;
|
||||||
|
|
||||||
|
/// A painted landscape behind a tinted stage's header, in place of the
|
||||||
|
/// flat gradient.
|
||||||
|
final List<Color>? scene;
|
||||||
|
|
||||||
|
final int sceneSeed;
|
||||||
|
|
||||||
const Sheet({
|
const Sheet({
|
||||||
super.key,
|
super.key,
|
||||||
required this.title,
|
required this.title,
|
||||||
@@ -40,33 +51,39 @@ class Sheet extends StatelessWidget {
|
|||||||
this.chrome,
|
this.chrome,
|
||||||
this.banner,
|
this.banner,
|
||||||
this.scrollable = true,
|
this.scrollable = true,
|
||||||
|
this.scene,
|
||||||
|
this.sceneSeed = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bool get _light => chrome == null && banner == null && scene == null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final Color chromeColor = chrome ?? colorPrimaryDark;
|
final bool light = _light;
|
||||||
|
final Color stage = chrome ?? colorPrimaryDark;
|
||||||
|
final Color ink = light ? colorInk : colorWhite;
|
||||||
|
final Color soft =
|
||||||
|
light ? colorInkSoft : colorWhite.withValues(alpha: 0.5);
|
||||||
|
|
||||||
return Scaffold(
|
final Widget header = SafeArea(
|
||||||
backgroundColor: chromeColor,
|
|
||||||
body: Column(
|
|
||||||
mainAxisSize: MainAxisSize.max,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
SafeArea(
|
|
||||||
bottom: false,
|
bottom: false,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 16),
|
padding: const EdgeInsets.fromLTRB(16, 6, 16, 18),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 44,
|
width: 46,
|
||||||
child: onBack == null
|
child: onBack == null
|
||||||
? null
|
? null
|
||||||
: _chromeButton(
|
: Align(
|
||||||
Icons.arrow_back_ios_new_rounded, onBack!),
|
alignment: Alignment.centerLeft,
|
||||||
|
child: glassCircle(
|
||||||
|
CupertinoIcons.arrow_left, onBack!,
|
||||||
|
onDark: !light),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -77,24 +94,25 @@ class Sheet extends StatelessWidget {
|
|||||||
eyebrow.toUpperCase(),
|
eyebrow.toUpperCase(),
|
||||||
9,
|
9,
|
||||||
TextType.Bold,
|
TextType.Bold,
|
||||||
color: colorWhite.withValues(alpha: 0.45),
|
color: soft,
|
||||||
letterSpacing: 1.2,
|
letterSpacing: 1.2,
|
||||||
align: TextAlign.center,
|
align: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 3),
|
const SizedBox(height: 3),
|
||||||
],
|
],
|
||||||
text(
|
text(
|
||||||
title,
|
title.toUpperCase(),
|
||||||
15,
|
14,
|
||||||
TextType.Medium,
|
TextType.Medium,
|
||||||
color: colorWhite,
|
color: ink,
|
||||||
|
letterSpacing: 0.6,
|
||||||
align: TextAlign.center,
|
align: TextAlign.center,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 44,
|
width: 46,
|
||||||
child: action == null
|
child: action == null
|
||||||
? null
|
? null
|
||||||
: Align(
|
: Align(
|
||||||
@@ -105,20 +123,21 @@ class Sheet extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (banner != null) ...[
|
if (banner != null) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 18),
|
||||||
banner!,
|
banner!,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
Expanded(
|
|
||||||
|
final Widget body = Expanded(
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorPrimaryLight,
|
color: light ? colorFrost : colorPrimaryLight,
|
||||||
borderRadius: const BorderRadius.only(
|
borderRadius: const BorderRadius.only(
|
||||||
topLeft: Radius.circular(28),
|
topLeft: Radius.circular(32),
|
||||||
topRight: Radius.circular(28),
|
topRight: Radius.circular(32),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
@@ -132,66 +151,107 @@ class Sheet extends StatelessWidget {
|
|||||||
child: child,
|
child: child,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final Widget column = ChromeTone(
|
||||||
|
light: light,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.max,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [header, body],
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (light) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: colorAmbientMid,
|
||||||
|
body: AmbientBackground(child: column),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tinted stage: the colour deepens downward and a soft light blooms in
|
||||||
|
// the corner, so it reads as lit rather than painted flat.
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: stage,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: <Color>[
|
||||||
|
Color.lerp(stage, colorWhite, 0.14)!,
|
||||||
|
stage,
|
||||||
|
Color.lerp(stage, colorBlack, 0.35)!,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (scene != null)
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 250,
|
||||||
|
child: ShaderMask(
|
||||||
|
shaderCallback: (Rect bounds) => const LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: <Color>[Color(0xFF000000), Color(0x00000000)],
|
||||||
|
stops: <double>[0.6, 1.0],
|
||||||
|
).createShader(bounds),
|
||||||
|
blendMode: BlendMode.dstIn,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
SceneArt(palette: scene!, seed: sceneSeed),
|
||||||
|
ColoredBox(color: colorBlack.withValues(alpha: 0.42)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: -120,
|
||||||
|
right: -90,
|
||||||
|
child: Container(
|
||||||
|
width: 300,
|
||||||
|
height: 300,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: RadialGradient(
|
||||||
|
colors: <Color>[
|
||||||
|
colorWhite.withValues(alpha: 0.16),
|
||||||
|
colorWhite.withValues(alpha: 0.0),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned.fill(child: column),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _chromeButton(IconData icon, VoidCallback onTap) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: onTap,
|
|
||||||
child: Container(
|
|
||||||
width: 38,
|
|
||||||
height: 38,
|
|
||||||
alignment: Alignment.center,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorWhite.withValues(alpha: 0.10),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: Icon(icon, size: 15, color: colorWhite),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A circular control for the black chrome, with an optional unread dot.
|
/// A round frosted control for the chrome, with an optional unread dot. It
|
||||||
|
/// matches the stage it sits on.
|
||||||
Widget chromeAction(
|
Widget chromeAction(
|
||||||
IconData icon,
|
IconData icon,
|
||||||
VoidCallback onTap, {
|
VoidCallback onTap, {
|
||||||
bool dotted = false,
|
bool dotted = false,
|
||||||
Color? dotColor,
|
Color? dotColor,
|
||||||
}) {
|
}) {
|
||||||
return GestureDetector(
|
return Builder(
|
||||||
onTap: onTap,
|
builder: (BuildContext context) => glassCircle(
|
||||||
child: Stack(
|
icon,
|
||||||
clipBehavior: Clip.none,
|
onTap,
|
||||||
children: [
|
onDark: !ChromeTone.isLight(context),
|
||||||
Container(
|
dotted: dotted,
|
||||||
width: 38,
|
dotColor: dotColor,
|
||||||
height: 38,
|
|
||||||
alignment: Alignment.center,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorWhite.withValues(alpha: 0.10),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: Icon(icon, size: 17, color: colorWhite),
|
|
||||||
),
|
|
||||||
if (dotted)
|
|
||||||
Positioned(
|
|
||||||
top: 1,
|
|
||||||
right: 1,
|
|
||||||
child: Container(
|
|
||||||
width: 9,
|
|
||||||
height: 9,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: dotColor ?? colorAccent,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(color: colorPrimaryDark, width: 1.5),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -215,12 +275,11 @@ Widget labelled(
|
|||||||
label.toUpperCase(),
|
label.toUpperCase(),
|
||||||
9,
|
9,
|
||||||
TextType.Bold,
|
TextType.Bold,
|
||||||
color: labelColor ?? colorGrey2,
|
color: labelColor ?? colorInkSoft,
|
||||||
letterSpacing: 1.0,
|
letterSpacing: 1.0,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 5),
|
const SizedBox(height: 5),
|
||||||
text(value, valueSize, valueType,
|
text(value, valueSize, valueType, color: valueColor ?? colorInk),
|
||||||
color: valueColor ?? colorPrimaryDark),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -247,18 +306,15 @@ Widget metaRow(List<Widget> items) {
|
|||||||
/// The oversized light display title that opens a section.
|
/// The oversized light display title that opens a section.
|
||||||
Widget displayTitle(String value, {Color? color, double size = 34}) {
|
Widget displayTitle(String value, {Color? color, double size = 34}) {
|
||||||
return text(value, size, TextType.Light,
|
return text(value, size, TextType.Light,
|
||||||
color: color ?? colorPrimaryDark, height: 1.15);
|
color: color ?? colorInk, height: 1.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A section break: hairline, then a small bold heading with an optional
|
/// A section heading: medium ink label, a soft caption beside it and an
|
||||||
/// trailing chip.
|
/// optional trailing control ("View all").
|
||||||
Widget sectionBreak(String heading, {Widget? trailing, String caption = ""}) {
|
Widget sectionBreak(String heading, {Widget? trailing, String caption = ""}) {
|
||||||
return Column(
|
return Padding(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
padding: const EdgeInsets.only(top: 6, bottom: 14),
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: Row(
|
||||||
children: [
|
|
||||||
hairline(margin: const EdgeInsets.only(bottom: 20)),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
@@ -266,13 +322,17 @@ Widget sectionBreak(String heading, {Widget? trailing, String caption = ""}) {
|
|||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
text(heading, 17, TextType.Bold, color: colorPrimaryDark),
|
text(heading, 16, TextType.Medium, color: colorInk),
|
||||||
if (caption.isNotEmpty) ...[
|
if (caption.isNotEmpty) ...[
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 8),
|
||||||
Padding(
|
Flexible(
|
||||||
padding: const EdgeInsets.only(bottom: 2),
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 1),
|
||||||
child: text(caption, 11, TextType.Regular,
|
child: text(caption, 11, TextType.Regular,
|
||||||
color: colorGrey2),
|
color: colorInkSoft,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -281,7 +341,5 @@ Widget sectionBreak(String heading, {Widget? trailing, String caption = ""}) {
|
|||||||
if (trailing != null) trailing,
|
if (trailing != null) trailing,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
114
frontend/lib/Grounded/designs/scene/SceneArt.dart
Normal 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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
114
frontend/lib/Grounded/designs/scene/ScenePainter.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import '../../about/external/data/Goal.dart';
|
|||||||
import '../../about/internal/application/TextType.dart';
|
import '../../about/internal/application/TextType.dart';
|
||||||
import '../../configs/Navigator.dart';
|
import '../../configs/Navigator.dart';
|
||||||
import '../../designs/Component.dart';
|
import '../../designs/Component.dart';
|
||||||
|
import '../../designs/GlassSurface.dart';
|
||||||
import '../../designs/Responsive.dart';
|
import '../../designs/Responsive.dart';
|
||||||
import '../../designs/Shell.dart';
|
import '../../designs/Shell.dart';
|
||||||
import '../../designs/buttons/Buttons.dart';
|
import '../../designs/buttons/Buttons.dart';
|
||||||
|
import '../../designs/scene/SceneArt.dart';
|
||||||
import '../../designs/text/Text.dart';
|
import '../../designs/text/Text.dart';
|
||||||
import '../../utils/Colors.dart';
|
import '../../utils/Colors.dart';
|
||||||
import '../../utils/CommonUtils.dart';
|
import '../../utils/CommonUtils.dart';
|
||||||
@@ -104,109 +106,133 @@ class GoalsState extends State<Goals> implements ConnectGoals {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The goal as a painted card in its own colour: the ambition up top, the
|
||||||
|
/// progress and the cost along the bottom.
|
||||||
Widget _goalCard(Goal goal) {
|
Widget _goalCard(Goal goal) {
|
||||||
final Color accent =
|
final Color accent = goalColor(goal.colourHex);
|
||||||
goal.slipping ? colorStandingGrounded : goalColor(goal.colourHex);
|
final Color soft = colorWhite.withValues(alpha: 0.78);
|
||||||
|
final bool overdue = goal.targetDate != null &&
|
||||||
|
goal.targetDate!.isBefore(DateTime.now());
|
||||||
|
|
||||||
return Container(
|
return GestureDetector(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
|
||||||
child: card(
|
|
||||||
onTap: () => _onOpenGoal(goal),
|
onTap: () => _onOpenGoal(goal),
|
||||||
child: Column(
|
child: Container(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
height: 214,
|
||||||
mainAxisSize: MainAxisSize.min,
|
margin: const EdgeInsets.only(bottom: 14),
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: goalColor(goal.colourHex),
|
borderRadius: BorderRadius.circular(26),
|
||||||
shape: BoxShape.circle,
|
boxShadow: const [
|
||||||
|
BoxShadow(
|
||||||
|
color: Color(0x262A3A30),
|
||||||
|
blurRadius: 24,
|
||||||
|
offset: Offset(0, 10),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
child: ClipRRect(
|
||||||
text(
|
borderRadius: BorderRadius.circular(26),
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
SceneArt(
|
||||||
|
palette: goalScene(accent),
|
||||||
|
seed: sceneSeed(goal.title),
|
||||||
|
figure: CupertinoIcons.flag_fill,
|
||||||
|
figureScale: 0.4,
|
||||||
|
figureAlignment: const Alignment(0.75, -0.35),
|
||||||
|
shade: true,
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
GlassSurface(
|
||||||
|
onDark: true,
|
||||||
|
radius: BorderRadius.circular(99),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10, vertical: 5),
|
||||||
|
child: text(
|
||||||
[
|
[
|
||||||
if (goal.category.isNotEmpty) goal.category,
|
if (goal.category.isNotEmpty) goal.category,
|
||||||
goal.targetDate == null
|
goal.targetDate == null
|
||||||
? "No deadline"
|
? "No deadline"
|
||||||
: targetLabel(goal.targetDate),
|
: targetLabel(goal.targetDate),
|
||||||
].join(" · ").toUpperCase(),
|
].join(" · "),
|
||||||
9,
|
10,
|
||||||
TextType.Bold,
|
TextType.Medium,
|
||||||
color: goal.targetDate != null &&
|
color: overdue ? colorTinted : colorWhite,
|
||||||
goal.targetDate!.isBefore(DateTime.now())
|
|
||||||
? colorStandingGrounded
|
|
||||||
: colorGrey2,
|
|
||||||
letterSpacing: 1.0,
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (goal.overdueTasks > 0)
|
||||||
|
pill("${goal.overdueTasks} late", colorWhite,
|
||||||
|
colorStandingGrounded,
|
||||||
|
textSize: 9),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const Spacer(),
|
||||||
text(goal.title, 19, TextType.Light,
|
text(goal.title, 21, TextType.Medium,
|
||||||
color: colorPrimaryDark,
|
color: colorWhite,
|
||||||
maxLines: 2,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis),
|
overflow: TextOverflow.ellipsis),
|
||||||
if (goal.description.isNotEmpty) ...[
|
if (goal.description.isNotEmpty) ...[
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 3),
|
||||||
text(goal.description, 12, TextType.Regular,
|
text(goal.description, 12, TextType.Regular,
|
||||||
color: colorGrey2,
|
color: soft,
|
||||||
maxLines: 2,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis),
|
overflow: TextOverflow.ellipsis),
|
||||||
],
|
],
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
if (goal.overdueTasks > 0)
|
|
||||||
pill("${goal.overdueTasks} late", colorStandingGrounded,
|
|
||||||
colorStandingGroundedBg, textSize: 9),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
meter(goal.progress, fill: accent),
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
meter(goal.progress,
|
||||||
|
fill: colorLime,
|
||||||
|
track: colorWhite.withValues(alpha: 0.25),
|
||||||
|
height: 5),
|
||||||
|
const SizedBox(height: 10),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: labelled(
|
child: _cardStat(
|
||||||
|
"${goal.completedTasks}/${goal.totalTasks}",
|
||||||
"Done",
|
"Done",
|
||||||
"${goal.completedTasks} of ${goal.totalTasks}",
|
soft),
|
||||||
valueSize: 13,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: labelled(
|
child: _cardStat(
|
||||||
"Remaining",
|
"${goal.remainingTasks}", "Remaining", soft),
|
||||||
"${goal.remainingTasks}",
|
|
||||||
valueSize: 13,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: labelled(
|
child: _cardStat(
|
||||||
"Debt",
|
formatDebt(goal.debtContribution), "Debt", soft,
|
||||||
formatDebt(goal.debtContribution),
|
alert: goal.debtContribution > 0),
|
||||||
valueSize: 13,
|
|
||||||
valueColor: goal.debtContribution > 0
|
|
||||||
? colorStandingGrounded
|
|
||||||
: colorPrimaryDark,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _cardStat(String value, String caption, Color tone,
|
||||||
|
{bool alert = false}) {
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
text(value, 18, TextType.Light,
|
||||||
|
color: alert ? colorTinted : colorWhite),
|
||||||
|
const SizedBox(width: 5),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
|
child: text(caption, 10, TextType.Regular, color: tone),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -12,10 +12,14 @@ import '../../about/internal/application/RoutinePhase.dart';
|
|||||||
import '../../about/internal/application/RoutineRun.dart';
|
import '../../about/internal/application/RoutineRun.dart';
|
||||||
import '../../about/internal/application/StepMode.dart';
|
import '../../about/internal/application/StepMode.dart';
|
||||||
import '../../about/internal/application/TextType.dart';
|
import '../../about/internal/application/TextType.dart';
|
||||||
|
import '../../designs/AmbientBackground.dart';
|
||||||
import '../../designs/Component.dart';
|
import '../../designs/Component.dart';
|
||||||
|
import '../../designs/DotDigits.dart';
|
||||||
|
import '../../designs/GlassSurface.dart';
|
||||||
import '../../designs/ProgressRing.dart';
|
import '../../designs/ProgressRing.dart';
|
||||||
import '../../designs/Shell.dart';
|
import '../../designs/Shell.dart';
|
||||||
import '../../designs/buttons/Buttons.dart';
|
import '../../designs/buttons/Buttons.dart';
|
||||||
|
import '../../designs/scene/SceneArt.dart';
|
||||||
import '../../designs/text/Text.dart';
|
import '../../designs/text/Text.dart';
|
||||||
import '../../utils/Colors.dart';
|
import '../../utils/Colors.dart';
|
||||||
import '../../utils/CommonUtils.dart';
|
import '../../utils/CommonUtils.dart';
|
||||||
@@ -24,9 +28,9 @@ import 'ConnectRoutineRunner.dart';
|
|||||||
import 'RoutineRunner.dart';
|
import 'RoutineRunner.dart';
|
||||||
import 'ViewRoutineRunner.dart';
|
import 'ViewRoutineRunner.dart';
|
||||||
|
|
||||||
/// The routine player. A coloured stage carries the phase — a ring that
|
/// The routine player. A painted scene carries the phase — a glass ring that
|
||||||
/// drains for timed work, a breathing circle for meditation — and the sheet
|
/// drains for timed work, a breathing circle for meditation — and the light
|
||||||
/// beneath it carries the chain: where you are, what is next, and the clock.
|
/// panel beneath it carries the chain on an arc, the step and the dotted clock.
|
||||||
class RoutineRunnerState extends State<RoutineRunner>
|
class RoutineRunnerState extends State<RoutineRunner>
|
||||||
with WidgetsBindingObserver, SingleTickerProviderStateMixin
|
with WidgetsBindingObserver, SingleTickerProviderStateMixin
|
||||||
implements ConnectRoutineRunner {
|
implements ConnectRoutineRunner {
|
||||||
@@ -50,7 +54,7 @@ class RoutineRunnerState extends State<RoutineRunner>
|
|||||||
|
|
||||||
final ScrollController _carousel = ScrollController();
|
final ScrollController _carousel = ScrollController();
|
||||||
|
|
||||||
static const double _carouselItem = 76;
|
static const double _carouselItem = 78;
|
||||||
|
|
||||||
/// Run time below which leaving logs nothing — opening the player by
|
/// Run time below which leaving logs nothing — opening the player by
|
||||||
/// mistake is not a broken chain.
|
/// mistake is not a broken chain.
|
||||||
@@ -94,7 +98,7 @@ class RoutineRunnerState extends State<RoutineRunner>
|
|||||||
child: AnnotatedRegion<SystemUiOverlayStyle>(
|
child: AnnotatedRegion<SystemUiOverlayStyle>(
|
||||||
value: SystemUiOverlayStyle.light,
|
value: SystemUiOverlayStyle.light,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: kindDeep(_run.chain.kind),
|
backgroundColor: colorAmbientMid,
|
||||||
body: _finished ? _summaryView() : _runnerView(),
|
body: _finished ? _summaryView() : _runnerView(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -230,10 +234,9 @@ class RoutineRunnerState extends State<RoutineRunner>
|
|||||||
if (!_carousel.hasClients) {
|
if (!_carousel.hasClients) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final double viewport = _carousel.position.viewportDimension;
|
// The list is padded by half a viewport either side, so step n is
|
||||||
final double target = (_run.phase.stepIndex * _carouselItem) -
|
// centred at exactly n item-widths of scroll.
|
||||||
(viewport / 2) +
|
final double target = _run.phase.stepIndex * _carouselItem;
|
||||||
(_carouselItem / 2);
|
|
||||||
|
|
||||||
_carousel.animateTo(
|
_carousel.animateTo(
|
||||||
target.clamp(0, _carousel.position.maxScrollExtent),
|
target.clamp(0, _carousel.position.maxScrollExtent),
|
||||||
@@ -354,41 +357,86 @@ class RoutineRunnerState extends State<RoutineRunner>
|
|||||||
// ── Views ─────────────────────────────────────────────────────────────────
|
// ── Views ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Widget _runnerView() {
|
Widget _runnerView() {
|
||||||
return Column(
|
return AmbientBackground(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
child: LayoutBuilder(
|
||||||
|
builder: (BuildContext context, BoxConstraints constraints) {
|
||||||
|
final double stageHeight = constraints.maxHeight * 0.48;
|
||||||
|
|
||||||
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
Expanded(flex: 5, child: _stage()),
|
Positioned(
|
||||||
Expanded(flex: 6, child: _panel()),
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: stageHeight + 70,
|
||||||
|
child: _scene(),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: stageHeight,
|
||||||
|
child: _stage(),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: stageHeight - 40,
|
||||||
|
bottom: 0,
|
||||||
|
child: _panel(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The top half: kind gradient, chrome controls and the phase visual.
|
/// The painted world the run happens in. Rest dims it to dusk.
|
||||||
|
Widget _scene() {
|
||||||
|
return ShaderMask(
|
||||||
|
shaderCallback: (Rect bounds) => const LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: <Color>[Color(0xFF000000), Color(0x00000000)],
|
||||||
|
stops: <double>[0.62, 1.0],
|
||||||
|
).createShader(bounds),
|
||||||
|
blendMode: BlendMode.dstIn,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
SceneArt(
|
||||||
|
palette: scenePalette(_run.chain.kind),
|
||||||
|
seed: sceneSeed(_run.chain.title),
|
||||||
|
),
|
||||||
|
AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 600),
|
||||||
|
color: colorNight.withValues(alpha: _run.phase.rest ? 0.45 : 0.08),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The top half: frosted controls and the phase visual over the scene.
|
||||||
Widget _stage() {
|
Widget _stage() {
|
||||||
final RoutineKind kind = _run.chain.kind;
|
final RoutineKind kind = _run.chain.kind;
|
||||||
|
|
||||||
return Container(
|
return SafeArea(
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: <Color>[
|
|
||||||
_run.phase.rest ? colorPrimaryDark2 : kindColor(kind),
|
|
||||||
kindDeep(kind),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: SafeArea(
|
|
||||||
bottom: false,
|
bottom: false,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 18),
|
padding: const EdgeInsets.fromLTRB(16, 6, 16, 50),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
chromeAction(CupertinoIcons.xmark, _onRequestExit),
|
glassCircle(CupertinoIcons.arrow_left, _onRequestExit,
|
||||||
Expanded(child: Center(child: _totalPill())),
|
onDark: true),
|
||||||
chromeAction(CupertinoIcons.list_bullet, _onOpenSteps),
|
const Spacer(),
|
||||||
|
_totalPill(),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
glassCircle(CupertinoIcons.ellipsis_vertical, _onOpenSteps,
|
||||||
|
onDark: true),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -402,18 +450,15 @@ class RoutineRunnerState extends State<RoutineRunner>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Total elapsed and planned remaining — the reference's "Time" pill.
|
/// Total elapsed and planned remaining — the reference's "Time" pill.
|
||||||
Widget _totalPill() {
|
Widget _totalPill() {
|
||||||
return Container(
|
return GlassSurface(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
|
onDark: true,
|
||||||
decoration: BoxDecoration(
|
radius: BorderRadius.circular(99),
|
||||||
color: colorWhite.withValues(alpha: 0.12),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@@ -426,68 +471,59 @@ class RoutineRunnerState extends State<RoutineRunner>
|
|||||||
height: 3,
|
height: 3,
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorWhite.withValues(alpha: 0.45),
|
color: colorWhite.withValues(alpha: 0.6),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
text("~${formatClock(_run.plannedRemaining())} left", 12,
|
text("~${formatClock(_run.plannedRemaining())} left", 12,
|
||||||
TextType.Regular,
|
TextType.Regular,
|
||||||
color: colorWhite.withValues(alpha: 0.6)),
|
color: colorWhite.withValues(alpha: 0.8)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A glass disc standing in the scene with the figure in it; the ring
|
||||||
|
/// drains as the phase runs down.
|
||||||
Widget _phaseRing() {
|
Widget _phaseRing() {
|
||||||
final RoutinePhase phase = _run.phase;
|
final RoutinePhase phase = _run.phase;
|
||||||
|
|
||||||
return Stack(
|
return ProgressRing(
|
||||||
alignment: Alignment.center,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 236,
|
|
||||||
height: 236,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: colorWhite.withValues(alpha: 0.04),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ProgressRing(
|
|
||||||
fraction: phase.countsDown ? _run.phaseProgress() : 0,
|
fraction: phase.countsDown ? _run.phaseProgress() : 0,
|
||||||
size: 208,
|
size: 180,
|
||||||
stroke: 4,
|
stroke: 5,
|
||||||
fill: colorWhite,
|
fill: colorWhite,
|
||||||
track: colorWhite.withValues(alpha: 0.12),
|
track: colorWhite.withValues(alpha: 0.22),
|
||||||
child: Container(
|
child: GlassSurface(
|
||||||
width: 166,
|
onDark: true,
|
||||||
height: 166,
|
radius: BorderRadius.circular(100),
|
||||||
alignment: Alignment.center,
|
child: SizedBox(
|
||||||
decoration: BoxDecoration(
|
width: 150,
|
||||||
shape: BoxShape.circle,
|
height: 150,
|
||||||
color: colorWhite.withValues(alpha: 0.08),
|
|
||||||
),
|
|
||||||
child: phase.rest
|
child: phase.rest
|
||||||
? Column(
|
? Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(CupertinoIcons.wind,
|
Icon(CupertinoIcons.wind, size: 38, color: colorWhite),
|
||||||
size: 34, color: colorWhite.withValues(alpha: 0.8)),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
text("REST", 11, TextType.Bold,
|
text("Rest", 14, TextType.Medium, color: colorWhite),
|
||||||
color: colorWhite.withValues(alpha: 0.7),
|
|
||||||
letterSpacing: 2),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
: Icon(
|
: Icon(
|
||||||
phase.step.mode == StepMode.Check
|
phase.step.mode == StepMode.Check
|
||||||
? CupertinoIcons.checkmark_alt
|
? CupertinoIcons.checkmark_alt
|
||||||
: kindIcon(_run.chain.kind),
|
: kindIcon(_run.chain.kind),
|
||||||
size: 64,
|
size: 70,
|
||||||
color: colorWhite,
|
color: colorWhite,
|
||||||
),
|
shadows: const <Shadow>[
|
||||||
),
|
Shadow(
|
||||||
),
|
color: Color(0x40000000),
|
||||||
|
blurRadius: 18,
|
||||||
|
offset: Offset(0, 6)),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,38 +534,44 @@ class RoutineRunnerState extends State<RoutineRunner>
|
|||||||
final double t = Curves.easeInOut.transform(_breath.value);
|
final double t = Curves.easeInOut.transform(_breath.value);
|
||||||
final bool inhaling = _breath.status == AnimationStatus.forward;
|
final bool inhaling = _breath.status == AnimationStatus.forward;
|
||||||
|
|
||||||
return Stack(
|
return SizedBox(
|
||||||
|
width: 240,
|
||||||
|
height: 240,
|
||||||
|
child: Stack(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 150 + 90 * t,
|
width: 140 + 96 * t,
|
||||||
height: 150 + 90 * t,
|
height: 140 + 96 * t,
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: colorWhite.withValues(alpha: 0.05),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
width: 130 + 60 * t,
|
|
||||||
height: 130 + 60 * t,
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: colorWhite.withValues(alpha: 0.10),
|
color: colorWhite.withValues(alpha: 0.10),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: colorWhite.withValues(alpha: 0.25), width: 1),
|
color: colorWhite.withValues(alpha: 0.35), width: 1),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
text(
|
GlassSurface(
|
||||||
|
onDark: true,
|
||||||
|
radius: BorderRadius.circular(200),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 120 + 50 * t,
|
||||||
|
height: 120 + 50 * t,
|
||||||
|
child: Center(
|
||||||
|
child: text(
|
||||||
!_run.running
|
!_run.running
|
||||||
? "Paused"
|
? "Paused"
|
||||||
: inhaling
|
: inhaling
|
||||||
? "Breathe in"
|
? "Breathe in"
|
||||||
: "Breathe out",
|
: "Breathe out",
|
||||||
17,
|
16,
|
||||||
TextType.Light,
|
TextType.Medium,
|
||||||
color: colorWhite,
|
color: colorWhite,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -537,67 +579,59 @@ class RoutineRunnerState extends State<RoutineRunner>
|
|||||||
|
|
||||||
Widget _stageStatus() {
|
Widget _stageStatus() {
|
||||||
final String status = !_run.running
|
final String status = !_run.running
|
||||||
? "PAUSED"
|
? "Paused"
|
||||||
: _run.phase.rest
|
: _run.phase.rest
|
||||||
? "RESTING"
|
? "Resting"
|
||||||
: "IN PROGRESS";
|
: "In progress";
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
|
children: [
|
||||||
|
GlassSurface(
|
||||||
|
onDark: true,
|
||||||
|
radius: BorderRadius.circular(99),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 7,
|
width: 7,
|
||||||
height: 7,
|
height: 7,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _run.running ? colorWhite : colorWarning,
|
color: _run.running ? colorLime : colorWarning,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 7),
|
||||||
text(status, 9, TextType.Bold,
|
text(status, 11, TextType.Medium, color: colorWhite),
|
||||||
color: colorWhite.withValues(alpha: 0.6), letterSpacing: 1.4),
|
|
||||||
if (_run.backgroundedCount > 0) ...[
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
pill("Left ${_run.backgroundedCount}×", colorWarning,
|
|
||||||
colorWarning.withValues(alpha: 0.14),
|
|
||||||
textSize: 9),
|
|
||||||
],
|
],
|
||||||
const Spacer(),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: _onTogglePause,
|
|
||||||
child: pill(
|
|
||||||
_run.running ? "Pause" : "Resume",
|
|
||||||
colorWhite,
|
|
||||||
colorWhite.withValues(alpha: 0.12),
|
|
||||||
icon: _run.running
|
|
||||||
? CupertinoIcons.pause_fill
|
|
||||||
: CupertinoIcons.play_fill,
|
|
||||||
textSize: 9,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (_run.backgroundedCount > 0) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
GlassSurface(
|
||||||
|
onDark: true,
|
||||||
|
radius: BorderRadius.circular(99),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
|
||||||
|
child: text("Left app ${_run.backgroundedCount}×", 11,
|
||||||
|
TextType.Medium,
|
||||||
|
color: colorWarmYellow),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The bottom sheet: chain carousel, step counter, clock and transport.
|
/// The lower half: the chain on an arc, the step, the dotted clock and the
|
||||||
|
/// transport.
|
||||||
Widget _panel() {
|
Widget _panel() {
|
||||||
final RoutinePhase phase = _run.phase;
|
final RoutinePhase phase = _run.phase;
|
||||||
final Color tone = kindColor(_run.chain.kind);
|
final Color tone = kindColor(_run.chain.kind);
|
||||||
|
|
||||||
return Container(
|
return SafeArea(
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorPrimaryLight,
|
|
||||||
borderRadius: BorderRadius.only(
|
|
||||||
topLeft: Radius.circular(28),
|
|
||||||
topRight: Radius.circular(28),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: SafeArea(
|
|
||||||
top: false,
|
top: false,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 14),
|
|
||||||
_stepCarousel(),
|
_stepCarousel(),
|
||||||
const SizedBox(height: 4),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
@@ -609,17 +643,17 @@ class RoutineRunnerState extends State<RoutineRunner>
|
|||||||
children: [
|
children: [
|
||||||
text(
|
text(
|
||||||
"${phase.stepIndex + 1} OF ${_run.chain.steps.length}",
|
"${phase.stepIndex + 1} OF ${_run.chain.steps.length}",
|
||||||
10,
|
13,
|
||||||
TextType.Bold,
|
TextType.Regular,
|
||||||
color: colorGrey2,
|
color: colorInkSoft,
|
||||||
letterSpacing: 1.4,
|
letterSpacing: 0.6,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 4),
|
||||||
text(
|
text(
|
||||||
phase.rest ? "Rest" : phase.step.title,
|
phase.rest ? "Rest" : phase.step.title,
|
||||||
24,
|
24,
|
||||||
TextType.Light,
|
TextType.Medium,
|
||||||
color: colorPrimaryDark,
|
color: colorInk,
|
||||||
align: TextAlign.center,
|
align: TextAlign.center,
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
@@ -640,7 +674,6 @@ class RoutineRunnerState extends State<RoutineRunner>
|
|||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -648,82 +681,96 @@ class RoutineRunnerState extends State<RoutineRunner>
|
|||||||
final List<RoutineStep> steps = _run.chain.steps;
|
final List<RoutineStep> steps = _run.chain.steps;
|
||||||
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 84,
|
height: 104,
|
||||||
child: ListView.builder(
|
child: LayoutBuilder(
|
||||||
|
builder: (BuildContext context, BoxConstraints constraints) {
|
||||||
|
final double side = (constraints.maxWidth - _carouselItem) / 2;
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
controller: _carousel,
|
controller: _carousel,
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: EdgeInsets.symmetric(horizontal: side),
|
||||||
itemCount: steps.length,
|
itemCount: steps.length,
|
||||||
itemExtent: _carouselItem,
|
itemExtent: _carouselItem,
|
||||||
itemBuilder: (context, index) => _carouselStep(index, steps[index]),
|
itemBuilder: (context, index) =>
|
||||||
|
_carouselStep(index, steps[index]),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Each step as a painted portrait. They sit on an arc that dips away from
|
||||||
|
/// the current one, like beads on a string.
|
||||||
Widget _carouselStep(int index, RoutineStep step) {
|
Widget _carouselStep(int index, RoutineStep step) {
|
||||||
final bool current = index == _run.phase.stepIndex;
|
final int current = _run.phase.stepIndex;
|
||||||
|
final bool isCurrent = index == current;
|
||||||
final bool done = step.completed;
|
final bool done = step.completed;
|
||||||
final Color tone = kindColor(_run.chain.kind);
|
final int distance = (index - current).abs();
|
||||||
final double size = current ? 50 : 40;
|
final double drop = (distance * distance * 5.0).clamp(0, 30).toDouble();
|
||||||
|
final double size = isCurrent ? 58 : 46;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => _onJumpTo(index),
|
onTap: () => _onJumpTo(index),
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: AnimatedPadding(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
padding: EdgeInsets.only(top: drop),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.start,
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
AnimatedContainer(
|
AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 250),
|
duration: const Duration(milliseconds: 300),
|
||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
margin: EdgeInsets.only(top: current ? 0 : 6),
|
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
alignment: Alignment.center,
|
padding: const EdgeInsets.all(2.5),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
gradient: current
|
color: isCurrent ? colorNight : colorWhite,
|
||||||
? LinearGradient(
|
boxShadow: const [
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: <Color>[tone, kindDeep(_run.chain.kind)],
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
color: current ? null : (done ? tone.withValues(alpha: 0.12) : colorWhite),
|
|
||||||
border: Border.all(
|
|
||||||
color: current
|
|
||||||
? colorWhite
|
|
||||||
: done
|
|
||||||
? tone.withValues(alpha: 0.3)
|
|
||||||
: colorBorder,
|
|
||||||
width: current ? 3 : 1,
|
|
||||||
),
|
|
||||||
boxShadow: current
|
|
||||||
? [
|
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: tone.withValues(alpha: 0.25),
|
color: Color(0x262A3A30),
|
||||||
blurRadius: 12,
|
blurRadius: 12,
|
||||||
offset: const Offset(0, 4),
|
offset: Offset(0, 5),
|
||||||
),
|
),
|
||||||
]
|
],
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
child: done && !current
|
child: ClipOval(
|
||||||
? Icon(Icons.check_rounded, size: 18, color: tone)
|
child: Stack(
|
||||||
: text("${index + 1}", current ? 15 : 12, TextType.Bold,
|
fit: StackFit.expand,
|
||||||
color: current ? colorWhite : colorGrey2),
|
children: [
|
||||||
|
SceneArt(
|
||||||
|
palette: scenePalette(_run.chain.kind),
|
||||||
|
seed: sceneSeed(step.title),
|
||||||
|
figure: stepModeIcon(step.mode),
|
||||||
|
figureScale: 0.42,
|
||||||
|
figureAlignment: const Alignment(0, 0.2),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
if (done && !isCurrent)
|
||||||
|
Container(
|
||||||
|
color: colorNight.withValues(alpha: 0.55),
|
||||||
|
child: Icon(Icons.check_rounded,
|
||||||
|
size: 20, color: colorLime),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 5),
|
||||||
text(
|
text(
|
||||||
step.title,
|
step.title,
|
||||||
10,
|
10,
|
||||||
current ? TextType.Bold : TextType.Regular,
|
isCurrent ? TextType.Medium : TextType.Regular,
|
||||||
color: current ? colorPrimaryDark : colorGrey2,
|
color: isCurrent ? colorInk : colorInkSoft,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
align: TextAlign.center,
|
align: TextAlign.center,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -731,8 +778,8 @@ class RoutineRunnerState extends State<RoutineRunner>
|
|||||||
return Row(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
text("SET ${phase.set} / ${phase.step.sets}", 9, TextType.Bold,
|
text("Set ${phase.set} of ${phase.step.sets}", 11, TextType.Medium,
|
||||||
color: tone, letterSpacing: 1.0),
|
color: tone),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
...List<Widget>.generate(phase.step.sets, (index) {
|
...List<Widget>.generate(phase.step.sets, (index) {
|
||||||
final int set = index + 1;
|
final int set = index + 1;
|
||||||
@@ -742,11 +789,11 @@ class RoutineRunnerState extends State<RoutineRunner>
|
|||||||
|
|
||||||
return AnimatedContainer(
|
return AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
width: active ? 18 : 7,
|
width: active ? 20 : 7,
|
||||||
height: 7,
|
height: 7,
|
||||||
margin: const EdgeInsets.only(right: 4),
|
margin: const EdgeInsets.only(right: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: filled || active ? tone : colorMuted,
|
color: filled || active ? tone : tone.withValues(alpha: 0.15),
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -755,36 +802,49 @@ class RoutineRunnerState extends State<RoutineRunner>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The dotted clock — counting down for timed work, up for everything else,
|
||||||
|
/// and the rep target for rep sets.
|
||||||
Widget _clock(RoutinePhase phase, Color tone) {
|
Widget _clock(RoutinePhase phase, Color tone) {
|
||||||
final String label;
|
final Color ink = _run.running ? colorInk : colorInkSoft;
|
||||||
final String value;
|
|
||||||
|
|
||||||
if (phase.countsDown) {
|
if (!phase.countsDown && phase.step.mode == StepMode.Reps) {
|
||||||
label = phase.rest ? "REST REMAINING" : "REMAINING";
|
return Column(
|
||||||
value = formatClock(_run.phaseRemaining());
|
mainAxisSize: MainAxisSize.min,
|
||||||
} else if (phase.step.mode == StepMode.Reps) {
|
children: [
|
||||||
label = "REPS · ${formatClock(_run.phaseElapsed())}";
|
Row(
|
||||||
value = "× ${phase.step.reps}";
|
mainAxisSize: MainAxisSize.min,
|
||||||
} else {
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
label = "ON THIS TASK";
|
children: [
|
||||||
value = formatClock(_run.phaseElapsed());
|
text("×", 44, TextType.Light, color: ink),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
DotDigits(value: "${phase.step.reps}", height: 62, color: ink),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
text("reps · ${formatClock(_run.phaseElapsed())} on this set", 12,
|
||||||
|
TextType.Regular,
|
||||||
|
color: colorInkSoft),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final int seconds =
|
||||||
|
phase.countsDown ? _run.phaseRemaining() : _run.phaseElapsed();
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
text(label, 9, TextType.Bold,
|
DotDigits(value: formatClock(seconds), height: 62, color: ink),
|
||||||
color: colorGrey2, letterSpacing: 1.6),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
text(value, 64, TextType.Light,
|
|
||||||
color: _run.running ? colorPrimaryDark : colorGrey2, height: 1.0),
|
|
||||||
if (phase.countsDown) ...[
|
if (phase.countsDown) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 180,
|
width: 180,
|
||||||
child: meter(_run.phaseProgress(),
|
child: meter(_run.phaseProgress(),
|
||||||
fill: phase.rest ? colorGrey2 : tone, height: 4),
|
fill: phase.rest ? colorInkSoft : tone, height: 4),
|
||||||
),
|
),
|
||||||
|
] else ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
text("on this task", 12, TextType.Regular, color: colorInkSoft),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -806,7 +866,7 @@ class RoutineRunnerState extends State<RoutineRunner>
|
|||||||
}
|
}
|
||||||
|
|
||||||
return text(line, 13, TextType.Regular,
|
return text(line, 13, TextType.Regular,
|
||||||
color: colorGrey2, align: TextAlign.center, maxLines: 2, height: 1.4);
|
color: colorInkSoft, align: TextAlign.center, maxLines: 2, height: 1.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _transport(RoutinePhase phase, Color tone) {
|
Widget _transport(RoutinePhase phase, Color tone) {
|
||||||
@@ -819,8 +879,8 @@ class RoutineRunnerState extends State<RoutineRunner>
|
|||||||
children: [
|
children: [
|
||||||
_roundControl(
|
_roundControl(
|
||||||
_haptics
|
_haptics
|
||||||
? CupertinoIcons.waveform
|
? CupertinoIcons.speaker_2
|
||||||
: CupertinoIcons.waveform_path_badge_minus,
|
: CupertinoIcons.speaker_slash,
|
||||||
_onToggleHaptics,
|
_onToggleHaptics,
|
||||||
),
|
),
|
||||||
_roundControl(CupertinoIcons.backward_end_fill, _onPrevious,
|
_roundControl(CupertinoIcons.backward_end_fill, _onPrevious,
|
||||||
@@ -829,33 +889,39 @@ class RoutineRunnerState extends State<RoutineRunner>
|
|||||||
onTap: _onPrimary,
|
onTap: _onPrimary,
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
width: 118,
|
width: 116,
|
||||||
height: 56,
|
height: 58,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorPrimaryDark,
|
color: colorNight,
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
boxShadow: const [
|
||||||
|
BoxShadow(
|
||||||
|
color: Color(0x331A1F1C),
|
||||||
|
blurRadius: 18,
|
||||||
|
offset: Offset(0, 8),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: countdown
|
child: countdown
|
||||||
? Icon(
|
? Icon(
|
||||||
_run.running
|
_run.running
|
||||||
? CupertinoIcons.pause_fill
|
? CupertinoIcons.pause_fill
|
||||||
: CupertinoIcons.play_fill,
|
: CupertinoIcons.play_fill,
|
||||||
size: 22,
|
size: 24,
|
||||||
color: colorKindWorkoutBg,
|
color: colorLime,
|
||||||
)
|
)
|
||||||
: Row(
|
: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.check_rounded,
|
Icon(Icons.check_rounded, size: 18, color: colorLime),
|
||||||
size: 18, color: colorKindWorkoutBg),
|
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
text(
|
text(
|
||||||
phase.step.mode == StepMode.Reps
|
phase.step.mode == StepMode.Reps
|
||||||
? "Set done"
|
? "Set done"
|
||||||
: "Done",
|
: "Done",
|
||||||
13,
|
13,
|
||||||
TextType.Bold,
|
TextType.Medium,
|
||||||
color: colorWhite,
|
color: colorWhite,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -871,23 +937,14 @@ class RoutineRunnerState extends State<RoutineRunner>
|
|||||||
|
|
||||||
Widget _roundControl(IconData icon, VoidCallback onTap,
|
Widget _roundControl(IconData icon, VoidCallback onTap,
|
||||||
{bool enabled = true}) {
|
{bool enabled = true}) {
|
||||||
return GestureDetector(
|
return Opacity(
|
||||||
onTap: enabled ? onTap : null,
|
opacity: enabled ? 1 : 0.4,
|
||||||
child: Container(
|
child: glassCircle(icon, enabled ? onTap : _onDisabled, size: 48),
|
||||||
width: 46,
|
|
||||||
height: 46,
|
|
||||||
alignment: Alignment.center,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorWhite,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(color: colorBorder),
|
|
||||||
),
|
|
||||||
child: Icon(icon,
|
|
||||||
size: 17, color: enabled ? colorPrimaryDark : colorGrey),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onDisabled() {}
|
||||||
|
|
||||||
/// Every step with its state. Tap one to jump there.
|
/// Every step with its state. Tap one to jump there.
|
||||||
Widget _stepsSheet(BuildContext sheetContext) {
|
Widget _stepsSheet(BuildContext sheetContext) {
|
||||||
final Color tone = kindColor(_run.chain.kind);
|
final Color tone = kindColor(_run.chain.kind);
|
||||||
@@ -977,107 +1034,138 @@ class RoutineRunnerState extends State<RoutineRunner>
|
|||||||
|
|
||||||
/// End of run. Held or not, it is stated plainly and then logged.
|
/// End of run. Held or not, it is stated plainly and then logged.
|
||||||
Widget _summaryView() {
|
Widget _summaryView() {
|
||||||
final RoutineKind kind = _run.chain.kind;
|
|
||||||
final bool held = _run.chain.complete;
|
final bool held = _run.chain.complete;
|
||||||
final int done = _run.chain.completedSteps;
|
final int done = _run.chain.completedSteps;
|
||||||
final int total = _run.chain.steps.length;
|
final int total = _run.chain.steps.length;
|
||||||
final bool broken =
|
final bool broken =
|
||||||
!held && _run.chain.partialCompletion < Thresholds.routinePartialFloor;
|
!held && _run.chain.partialCompletion < Thresholds.routinePartialFloor;
|
||||||
final Color label = colorWhite.withValues(alpha: 0.5);
|
|
||||||
|
|
||||||
return Container(
|
return AmbientBackground(
|
||||||
decoration: BoxDecoration(
|
child: Stack(
|
||||||
gradient: LinearGradient(
|
children: [
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 420,
|
||||||
|
child: ShaderMask(
|
||||||
|
shaderCallback: (Rect bounds) => const LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: <Color>[kindColor(kind), kindDeep(kind)],
|
colors: <Color>[Color(0xFF000000), Color(0x00000000)],
|
||||||
|
stops: <double>[0.5, 1.0],
|
||||||
|
).createShader(bounds),
|
||||||
|
blendMode: BlendMode.dstIn,
|
||||||
|
child: SceneArt(
|
||||||
|
palette: scenePalette(_run.chain.kind),
|
||||||
|
seed: sceneSeed(_run.chain.title),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: SafeArea(
|
),
|
||||||
|
SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(28, 20, 28, 24),
|
padding: const EdgeInsets.fromLTRB(24, 20, 24, 24),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
text(
|
Row(
|
||||||
held ? "CHAIN HELD" : (broken ? "CHAIN BROKEN" : "PARTIAL"),
|
children: [
|
||||||
10,
|
GlassSurface(
|
||||||
TextType.Bold,
|
onDark: true,
|
||||||
color: label,
|
radius: BorderRadius.circular(99),
|
||||||
letterSpacing: 1.6,
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12, vertical: 7),
|
||||||
|
child: text(
|
||||||
|
held
|
||||||
|
? "Chain held"
|
||||||
|
: (broken ? "Chain broken" : "Partial"),
|
||||||
|
12,
|
||||||
|
TextType.Medium,
|
||||||
|
color: colorWhite,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
),
|
||||||
text(_run.chain.title, 36, TextType.Light,
|
],
|
||||||
color: colorWhite, height: 1.1),
|
),
|
||||||
const SizedBox(height: 14),
|
const Spacer(),
|
||||||
|
Center(
|
||||||
|
child: ProgressRing(
|
||||||
|
fraction: _run.chain.partialCompletion,
|
||||||
|
size: 190,
|
||||||
|
stroke: 6,
|
||||||
|
fill: held ? colorLime : colorWhite,
|
||||||
|
track: colorWhite.withValues(alpha: 0.3),
|
||||||
|
child: GlassSurface(
|
||||||
|
onDark: true,
|
||||||
|
radius: BorderRadius.circular(100),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 158,
|
||||||
|
height: 158,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
text("$done/$total", 42, TextType.Light,
|
||||||
|
color: colorWhite),
|
||||||
|
text("steps", 12, TextType.Regular,
|
||||||
|
color: colorWhite.withValues(alpha: 0.8)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 36),
|
||||||
|
text(_run.chain.title, 32, TextType.Medium,
|
||||||
|
color: colorInk, height: 1.1, letterSpacing: -0.6),
|
||||||
|
const SizedBox(height: 8),
|
||||||
text(
|
text(
|
||||||
held
|
held
|
||||||
? "Every step, in order. That is the whole point."
|
? "Every step, in order. That is the whole point."
|
||||||
: "$done of $total steps. This goes in as partial — honest, not hidden.",
|
: "$done of $total steps. This goes in as partial — honest, not hidden.",
|
||||||
14,
|
14,
|
||||||
TextType.Regular,
|
TextType.Regular,
|
||||||
color: colorWhite.withValues(alpha: 0.7),
|
color: colorInkSoft,
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const SizedBox(height: 22),
|
||||||
Center(
|
GlassSurface(
|
||||||
child: ProgressRing(
|
radius: BorderRadius.circular(24),
|
||||||
fraction: _run.chain.partialCompletion,
|
tint: colorGlassStrong,
|
||||||
size: 180,
|
padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
|
||||||
stroke: 5,
|
child: Row(
|
||||||
fill: colorWhite,
|
|
||||||
track: colorWhite.withValues(alpha: 0.14),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
text("$done/$total", 40, TextType.Light,
|
|
||||||
color: colorWhite),
|
|
||||||
text("STEPS", 9, TextType.Bold,
|
|
||||||
color: label, letterSpacing: 1.4),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
Row(
|
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: labelled("Time", formatClock(_run.elapsedSeconds()),
|
child: labelled(
|
||||||
valueSize: 22,
|
"Time", formatClock(_run.elapsedSeconds()),
|
||||||
valueType: TextType.Light,
|
valueSize: 22, valueType: TextType.Light),
|
||||||
valueColor: colorWhite,
|
|
||||||
labelColor: label),
|
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: labelled("Skipped", "${_run.skippedCount}",
|
child: labelled("Skipped", "${_run.skippedCount}",
|
||||||
valueSize: 22,
|
valueSize: 22, valueType: TextType.Light),
|
||||||
valueType: TextType.Light,
|
|
||||||
valueColor: colorWhite,
|
|
||||||
labelColor: label),
|
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: labelled("Left app", "${_run.backgroundedCount}×",
|
child: labelled(
|
||||||
|
"Left app", "${_run.backgroundedCount}×",
|
||||||
valueSize: 22,
|
valueSize: 22,
|
||||||
valueType: TextType.Light,
|
valueType: TextType.Light,
|
||||||
valueColor: _run.backgroundedCount > 0
|
valueColor: _run.backgroundedCount > 0
|
||||||
? colorWarning
|
? colorWarning
|
||||||
: colorWhite,
|
: colorInk),
|
||||||
labelColor: label),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
),
|
||||||
|
const Spacer(),
|
||||||
roundedCornerButton(
|
roundedCornerButton(
|
||||||
"Log it",
|
"Log it",
|
||||||
_onLog,
|
_onLog,
|
||||||
background: colorWhite,
|
|
||||||
foreground: colorPrimaryDark,
|
|
||||||
icon: CupertinoIcons.checkmark,
|
icon: CupertinoIcons.checkmark,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ import '../../about/internal/application/CommitmentClass.dart';
|
|||||||
import '../../about/internal/application/RoutineKind.dart';
|
import '../../about/internal/application/RoutineKind.dart';
|
||||||
import '../../about/internal/application/TextType.dart';
|
import '../../about/internal/application/TextType.dart';
|
||||||
import '../../configs/Navigator.dart';
|
import '../../configs/Navigator.dart';
|
||||||
|
import '../../designs/AmbientBackground.dart';
|
||||||
import '../../designs/Component.dart';
|
import '../../designs/Component.dart';
|
||||||
|
import '../../designs/GlassSurface.dart';
|
||||||
import '../../designs/ProgressRing.dart';
|
import '../../designs/ProgressRing.dart';
|
||||||
import '../../designs/Responsive.dart';
|
import '../../designs/Responsive.dart';
|
||||||
import '../../designs/Shell.dart';
|
import '../../designs/Shell.dart';
|
||||||
import '../../designs/buttons/Buttons.dart';
|
import '../../designs/buttons/Buttons.dart';
|
||||||
|
import '../../designs/scene/SceneArt.dart';
|
||||||
import '../../designs/text/Text.dart';
|
import '../../designs/text/Text.dart';
|
||||||
import '../../utils/Colors.dart';
|
import '../../utils/Colors.dart';
|
||||||
import '../../utils/CommonUtils.dart';
|
import '../../utils/CommonUtils.dart';
|
||||||
@@ -53,6 +56,7 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _initiate() {
|
void _initiate() {
|
||||||
|
_kind = widget.kind;
|
||||||
_model?.loadRoutines();
|
_model?.loadRoutines();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,31 +154,35 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
|
|||||||
// ── Views ─────────────────────────────────────────────────────────────────
|
// ── Views ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Widget _mobileView(BoxConstraints constraints) {
|
Widget _mobileView(BoxConstraints constraints) {
|
||||||
return Sheet(
|
return Scaffold(
|
||||||
eyebrow: "${_routines.length} routine${_routines.length == 1 ? "" : "s"}",
|
backgroundColor: colorAmbientMid,
|
||||||
title: "Routines",
|
body: AmbientBackground(
|
||||||
onBack: _onBack,
|
child: SingleChildScrollView(
|
||||||
action: chromeAction(CupertinoIcons.add, _onBuild),
|
padding: const EdgeInsets.only(bottom: 40),
|
||||||
banner: _routines.isEmpty ? null : _todayBanner(),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
text(
|
_hero(),
|
||||||
"${weekdayName(DateTime.now().weekday).toUpperCase()} · ${_today.length} DUE",
|
Padding(
|
||||||
11,
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 0),
|
||||||
TextType.Bold,
|
child: Column(
|
||||||
color: colorGrey2,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
letterSpacing: 1.2,
|
mainAxisSize: MainAxisSize.min,
|
||||||
),
|
children: [
|
||||||
const SizedBox(height: 8),
|
if (_routines.isNotEmpty) ...[
|
||||||
displayTitle(_headline()),
|
_todayStrip(),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 26),
|
||||||
|
],
|
||||||
|
sectionBreak("Category",
|
||||||
|
caption: _kind == null ? "all kinds" : "tap again to clear"),
|
||||||
_kindRail(),
|
_kindRail(),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 22),
|
||||||
if (_filtered.isEmpty)
|
if (_filtered.isEmpty)
|
||||||
emptyState(
|
emptyState(
|
||||||
_kind == null ? CupertinoIcons.square_stack_3d_up : kindIcon(_kind!),
|
_kind == null
|
||||||
|
? CupertinoIcons.square_stack_3d_up
|
||||||
|
: kindIcon(_kind!),
|
||||||
_kind == null
|
_kind == null
|
||||||
? "No routines yet"
|
? "No routines yet"
|
||||||
: "No ${kindLabel(_kind!).toLowerCase()} routines",
|
: "No ${kindLabel(_kind!).toLowerCase()} routines",
|
||||||
@@ -182,18 +190,19 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
|
|||||||
)
|
)
|
||||||
else ...[
|
else ...[
|
||||||
sectionBreak(
|
sectionBreak(
|
||||||
"Up next",
|
"Featured",
|
||||||
caption: _today.isEmpty ? "from your library" : "today",
|
caption: _today.isEmpty ? "from your library" : "up next today",
|
||||||
),
|
),
|
||||||
_featuredRail(),
|
_featuredRail(),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
if (_today.isNotEmpty) ...[
|
if (_today.isNotEmpty) ...[
|
||||||
sectionBreak(
|
sectionBreak(
|
||||||
"Scheduled today",
|
"Active today",
|
||||||
caption: "${_today.where((item) => item.complete).length} of ${_today.length} done",
|
caption:
|
||||||
|
"${_today.where((item) => item.complete).length} of ${_today.length} done",
|
||||||
),
|
),
|
||||||
..._today.map(_routineRow),
|
..._today.map(_routineRow),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 18),
|
||||||
],
|
],
|
||||||
if (_rest.isNotEmpty) ...[
|
if (_rest.isNotEmpty) ...[
|
||||||
sectionBreak("Library", caption: "${_rest.length} more"),
|
sectionBreak("Library", caption: "${_rest.length} more"),
|
||||||
@@ -208,25 +217,103 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The image header: a painted scene for the selected kind, fading into
|
||||||
|
/// the backdrop, with frosted controls and the day's headline on it.
|
||||||
|
Widget _hero() {
|
||||||
|
final RoutineKind shown = _kind ?? RoutineKind.Workout;
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: 330,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
ShaderMask(
|
||||||
|
shaderCallback: (Rect bounds) => const LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: <Color>[Color(0xFF000000), Color(0x00000000)],
|
||||||
|
stops: <double>[0.55, 1.0],
|
||||||
|
).createShader(bounds),
|
||||||
|
blendMode: BlendMode.dstIn,
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 450),
|
||||||
|
child: SceneArt(
|
||||||
|
key: ValueKey<RoutineKind>(shown),
|
||||||
|
palette: scenePalette(shown),
|
||||||
|
seed: 40 + shown.index,
|
||||||
|
figure: kindIcon(shown),
|
||||||
|
figureScale: 0.36,
|
||||||
|
figureAlignment: const Alignment(0.45, -0.1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: SafeArea(
|
||||||
|
bottom: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 6, 16, 0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
glassCircle(CupertinoIcons.arrow_left, _onBack),
|
||||||
|
const Spacer(),
|
||||||
|
glassCircle(CupertinoIcons.add, _onBuild),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
bottom: 18,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
text(
|
||||||
|
"${weekdayName(DateTime.now().weekday)} · ${_today.length} due",
|
||||||
|
13,
|
||||||
|
TextType.Regular,
|
||||||
|
color: colorInkSoft,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
text(_headline(), 32, TextType.Medium,
|
||||||
|
color: colorInk, height: 1.1, letterSpacing: -0.8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _headline() {
|
String _headline() {
|
||||||
if (_routines.isEmpty) {
|
if (_routines.isEmpty) {
|
||||||
return "Build the things you do every time.";
|
return "Build the things\nyou do every time.";
|
||||||
}
|
}
|
||||||
final int open = _today.where((item) => !item.complete).length;
|
final int open = _today.where((item) => !item.complete).length;
|
||||||
if (_today.isEmpty) {
|
if (_today.isEmpty) {
|
||||||
return "Nothing scheduled. Run one anyway.";
|
return "Nothing scheduled.\nRun one anyway.";
|
||||||
}
|
}
|
||||||
if (open == 0) {
|
if (open == 0) {
|
||||||
return "Every chain held today.";
|
return "Every chain\nheld today.";
|
||||||
}
|
}
|
||||||
return "Here's what to hold today.";
|
return "Here's what to\nhold today.";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The strip in the black chrome: today's load at a glance.
|
/// Today's load at a glance, on glass.
|
||||||
Widget _todayBanner() {
|
Widget _todayStrip() {
|
||||||
final DateTime now = DateTime.now();
|
final DateTime now = DateTime.now();
|
||||||
final List<RoutineChain> due =
|
final List<RoutineChain> due =
|
||||||
_routines.where((item) => item.scheduledOn(now)).toList();
|
_routines.where((item) => item.scheduledOn(now)).toList();
|
||||||
@@ -242,112 +329,117 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
|
|||||||
.toList()
|
.toList()
|
||||||
..sort((a, b) => a.startMinute.compareTo(b.startMinute));
|
..sort((a, b) => a.startMinute.compareTo(b.startMinute));
|
||||||
|
|
||||||
final Color label = colorWhite.withValues(alpha: 0.45);
|
return GlassSurface(
|
||||||
|
radius: BorderRadius.circular(24),
|
||||||
return Container(
|
tint: colorGlassStrong,
|
||||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorWhite.withValues(alpha: 0.08),
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: labelled("Due today", "${due.length}",
|
child: _stripStat(CupertinoIcons.calendar_today, "${due.length}",
|
||||||
valueSize: 22,
|
"Due today"),
|
||||||
valueType: TextType.Light,
|
|
||||||
valueColor: colorWhite,
|
|
||||||
labelColor: label),
|
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: labelled("Planned", formatMinutes(seconds / 60),
|
child: _stripStat(CupertinoIcons.clock,
|
||||||
valueSize: 22,
|
formatMinutes(seconds / 60), "Planned"),
|
||||||
valueType: TextType.Light,
|
|
||||||
valueColor: colorWhite,
|
|
||||||
labelColor: label),
|
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: labelled(
|
child: _stripStat(
|
||||||
"Next",
|
CupertinoIcons.arrow_right_circle,
|
||||||
ahead.isEmpty ? "—" : formatStartMinute(ahead.first.startMinute),
|
ahead.isEmpty
|
||||||
valueSize: 22,
|
? "—"
|
||||||
valueType: TextType.Light,
|
: formatStartMinute(ahead.first.startMinute),
|
||||||
valueColor: colorWhite,
|
"Next"),
|
||||||
labelColor: label),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Round category tiles — tap to filter, tap again to clear.
|
Widget _stripStat(IconData icon, String value, String label) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
text(value, 22, TextType.Light, color: colorInk),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 11, color: colorInkSoft),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
text(label, 10, TextType.Regular, color: colorInkSoft),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Round painted portraits — tap to filter, tap again to clear.
|
||||||
Widget _kindRail() {
|
Widget _kindRail() {
|
||||||
final List<RoutineKind?> options = <RoutineKind?>[null, ...RoutineKind.values];
|
final List<RoutineKind?> options = <RoutineKind?>[null, ...RoutineKind.values];
|
||||||
|
|
||||||
return SizedBox(
|
return Row(
|
||||||
height: 96,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
child: ListView.separated(
|
children: options.map(_kindTile).toList(),
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
itemCount: options.length,
|
|
||||||
separatorBuilder: (context, index) => const SizedBox(width: 14),
|
|
||||||
itemBuilder: (context, index) => _kindTile(options[index]),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _kindTile(RoutineKind? kind) {
|
Widget _kindTile(RoutineKind? kind) {
|
||||||
final bool selected = _kind == kind;
|
final bool selected = _kind == kind;
|
||||||
final Color tone = kind == null ? colorPrimaryDark : kindColor(kind);
|
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => _onSelectKind(kind),
|
onTap: () => _onSelectKind(kind),
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 70,
|
width: 74,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
AnimatedContainer(
|
AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 180),
|
duration: const Duration(milliseconds: 200),
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
width: 60,
|
width: 64,
|
||||||
height: 60,
|
height: 64,
|
||||||
alignment: Alignment.center,
|
padding: const EdgeInsets.all(2.5),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
gradient: selected
|
color: selected ? colorNight : colorWhite,
|
||||||
? LinearGradient(
|
boxShadow: const [
|
||||||
begin: Alignment.topLeft,
|
BoxShadow(
|
||||||
end: Alignment.bottomRight,
|
color: Color(0x1F2A3A30),
|
||||||
colors: <Color>[
|
blurRadius: 14,
|
||||||
tone,
|
offset: Offset(0, 6),
|
||||||
kind == null ? colorPrimaryDark2 : kindDeep(kind),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
child: ClipOval(
|
||||||
|
child: kind == null
|
||||||
|
? Container(
|
||||||
|
color: colorWhite,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Icon(CupertinoIcons.square_grid_2x2_fill,
|
||||||
|
size: 22, color: colorInk),
|
||||||
)
|
)
|
||||||
: null,
|
: SceneArt(
|
||||||
color: selected ? null : colorWhite,
|
palette: scenePalette(kind),
|
||||||
border: Border.all(
|
seed: 3 + kind.index * 7,
|
||||||
color: selected ? tone : colorBorder,
|
figure: kindIcon(kind),
|
||||||
width: selected ? 2 : 1,
|
figureScale: 0.48,
|
||||||
|
figureAlignment: const Alignment(0, 0.25),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Icon(
|
|
||||||
kind == null ? CupertinoIcons.square_grid_2x2_fill : kindIcon(kind),
|
|
||||||
size: 22,
|
|
||||||
color: selected ? colorWhite : tone,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
text(
|
text(
|
||||||
kind == null ? "All" : kindLabel(kind),
|
kind == null ? "All" : kindLabel(kind),
|
||||||
11,
|
11,
|
||||||
selected ? TextType.Bold : TextType.Regular,
|
selected ? TextType.Medium : TextType.Regular,
|
||||||
color: selected ? colorPrimaryDark : colorGrey2,
|
color: selected ? colorInk : colorInkSoft,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 1),
|
||||||
text("${_countFor(kind)}", 9, TextType.Bold,
|
text("${_countFor(kind)}", 10, TextType.Regular,
|
||||||
color: colorGrey, letterSpacing: 0.6),
|
color: colorInkSoft),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -358,7 +450,7 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
|
|||||||
final List<RoutineChain> items = _upNext;
|
final List<RoutineChain> items = _upNext;
|
||||||
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 214,
|
height: 240,
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
@@ -369,129 +461,107 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The large gradient card. The kind's icon sits oversized in the corner as
|
/// The image-led card: the painted scene, a glass chip, the title and three
|
||||||
/// the card's picture — routines have no stock photography.
|
/// light numbers along the bottom.
|
||||||
Widget _featuredCard(RoutineChain routine) {
|
Widget _featuredCard(RoutineChain routine) {
|
||||||
final Color tone = kindColor(routine.kind);
|
final Color label = colorWhite.withValues(alpha: 0.75);
|
||||||
final Color label = colorWhite.withValues(alpha: 0.55);
|
final bool workout = routine.kind == RoutineKind.Workout;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => _onPreview(routine),
|
onTap: () => _onPreview(routine),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 248,
|
width: 200,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(26),
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: <Color>[tone, kindDeep(routine.kind)],
|
|
||||||
),
|
|
||||||
boxShadow: const [
|
boxShadow: const [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Color(0x08000000),
|
color: Color(0x262A3A30),
|
||||||
blurRadius: 18,
|
blurRadius: 24,
|
||||||
offset: Offset(0, 4),
|
offset: Offset(0, 10),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
clipBehavior: Clip.antiAlias,
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(26),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
Positioned(
|
SceneArt(
|
||||||
right: -26,
|
palette: scenePalette(routine.kind),
|
||||||
bottom: 34,
|
seed: sceneSeed(routine.title),
|
||||||
child: Icon(
|
figure: kindIcon(routine.kind),
|
||||||
kindIcon(routine.kind),
|
figureScale: 0.44,
|
||||||
size: 150,
|
figureAlignment: const Alignment(0.1, -0.25),
|
||||||
color: colorWhite.withValues(alpha: 0.08),
|
shade: true,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
pill(kindLabel(routine.kind), colorWhite,
|
GlassSurface(
|
||||||
colorWhite.withValues(alpha: 0.14),
|
onDark: true,
|
||||||
textSize: 9),
|
radius: BorderRadius.circular(99),
|
||||||
if (routine.commitmentClass ==
|
padding: const EdgeInsets.symmetric(
|
||||||
CommitmentClass.NonNegotiable) ...[
|
horizontal: 10, vertical: 5),
|
||||||
const SizedBox(width: 6),
|
child: text(
|
||||||
pill("Must hold", colorWhite,
|
routine.commitmentClass ==
|
||||||
colorWhite.withValues(alpha: 0.14),
|
CommitmentClass.NonNegotiable
|
||||||
textSize: 9),
|
? "Must hold"
|
||||||
],
|
: kindLabel(routine.kind),
|
||||||
const Spacer(),
|
10,
|
||||||
Container(
|
TextType.Medium,
|
||||||
width: 34,
|
|
||||||
height: 34,
|
|
||||||
alignment: Alignment.center,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorWhite,
|
color: colorWhite,
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
),
|
||||||
child: Icon(CupertinoIcons.play_fill,
|
|
||||||
size: 14, color: kindDeep(routine.kind)),
|
|
||||||
),
|
),
|
||||||
|
const Spacer(),
|
||||||
|
glassCircle(CupertinoIcons.play_fill,
|
||||||
|
() => _onStart(routine),
|
||||||
|
size: 32, onDark: true),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (routine.startMinute >= 0) ...[
|
if (routine.startMinute >= 0)
|
||||||
text(
|
text(
|
||||||
"${formatStartMinute(routine.startMinute)} · ${formatDays(routine.days)}"
|
"${formatStartMinute(routine.startMinute)} · ${formatDays(routine.days)}",
|
||||||
.toUpperCase(),
|
10,
|
||||||
9,
|
TextType.Regular,
|
||||||
TextType.Bold,
|
|
||||||
color: label,
|
color: label,
|
||||||
letterSpacing: 1.0,
|
maxLines: 1,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 3),
|
||||||
],
|
text(routine.title, 17, TextType.Medium,
|
||||||
text(routine.title, 21, TextType.Light,
|
|
||||||
color: colorWhite,
|
color: colorWhite,
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis),
|
||||||
height: 1.15),
|
const SizedBox(height: 10),
|
||||||
const SizedBox(height: 14),
|
|
||||||
Container(
|
|
||||||
height: 1,
|
|
||||||
color: colorWhite.withValues(alpha: 0.12),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: labelled(
|
child: _cardStat(
|
||||||
"Minutes",
|
|
||||||
"${(routine.totalSeconds / 60).round()}",
|
"${(routine.totalSeconds / 60).round()}",
|
||||||
valueSize: 17,
|
CupertinoIcons.clock,
|
||||||
valueColor: colorWhite,
|
"Min",
|
||||||
labelColor: label,
|
label),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: labelled(
|
child: _cardStat("${routine.steps.length}",
|
||||||
"Steps",
|
CupertinoIcons.list_bullet, "Steps", label),
|
||||||
"${routine.steps.length}",
|
|
||||||
valueSize: 17,
|
|
||||||
valueColor: colorWhite,
|
|
||||||
labelColor: label,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: labelled(
|
child: _cardStat(
|
||||||
routine.kind == RoutineKind.Workout ? "Sets" : "Starts",
|
workout
|
||||||
routine.kind == RoutineKind.Workout
|
|
||||||
? "${routine.totalSets}"
|
? "${routine.totalSets}"
|
||||||
: routine.startMinute < 0
|
: routine.startMinute < 0
|
||||||
? "Any"
|
? "Any"
|
||||||
: formatStartMinute(routine.startMinute),
|
: formatStartMinute(routine.startMinute),
|
||||||
valueSize: 17,
|
workout
|
||||||
valueColor: colorWhite,
|
? CupertinoIcons.repeat
|
||||||
labelColor: label,
|
: CupertinoIcons.alarm,
|
||||||
),
|
workout ? "Sets" : "Starts",
|
||||||
|
label),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -501,6 +571,25 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _cardStat(String value, IconData icon, String caption, Color tone) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
text(value, 20, TextType.Light, color: colorWhite, maxLines: 1),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 10, color: tone),
|
||||||
|
const SizedBox(width: 3),
|
||||||
|
text(caption, 9, TextType.Regular, color: tone),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,7 +597,7 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
|
|||||||
final Color tone = kindColor(routine.kind);
|
final Color tone = kindColor(routine.kind);
|
||||||
final bool weighted = routine.commitmentClass != CommitmentClass.Standard;
|
final bool weighted = routine.commitmentClass != CommitmentClass.Standard;
|
||||||
|
|
||||||
final List<String> eyebrow = <String>[
|
final List<String> kicker = <String>[
|
||||||
if (weighted) classLabel(routine.commitmentClass),
|
if (weighted) classLabel(routine.commitmentClass),
|
||||||
if (routine.startMinute >= 0) formatStartMinute(routine.startMinute),
|
if (routine.startMinute >= 0) formatStartMinute(routine.startMinute),
|
||||||
formatDays(routine.days),
|
formatDays(routine.days),
|
||||||
@@ -517,45 +606,43 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
|
|||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 10),
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
child: card(
|
child: card(
|
||||||
padding: const EdgeInsets.fromLTRB(10, 10, 14, 10),
|
padding: const EdgeInsets.fromLTRB(8, 8, 12, 8),
|
||||||
onTap: () => _onPreview(routine),
|
onTap: () => _onPreview(routine),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
SizedBox(
|
||||||
width: 58,
|
width: 70,
|
||||||
height: 58,
|
height: 70,
|
||||||
decoration: BoxDecoration(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(18),
|
||||||
gradient: LinearGradient(
|
child: SceneArt(
|
||||||
begin: Alignment.topLeft,
|
palette: scenePalette(routine.kind),
|
||||||
end: Alignment.bottomRight,
|
seed: sceneSeed(routine.title),
|
||||||
colors: <Color>[kindBackground(routine.kind), colorWhite],
|
figure: kindIcon(routine.kind),
|
||||||
|
figureScale: 0.46,
|
||||||
|
figureAlignment: const Alignment(0, 0.1),
|
||||||
),
|
),
|
||||||
border: Border.all(color: tone.withValues(alpha: 0.15)),
|
|
||||||
),
|
),
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Icon(kindIcon(routine.kind), size: 24, color: tone),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
text(
|
text(
|
||||||
eyebrow.join(" · ").toUpperCase(),
|
kicker.join(" · "),
|
||||||
9,
|
10,
|
||||||
TextType.Bold,
|
TextType.Medium,
|
||||||
color: weighted
|
color: weighted
|
||||||
? classColor(routine.commitmentClass)
|
? classColor(routine.commitmentClass)
|
||||||
: colorGrey2,
|
: tone,
|
||||||
letterSpacing: 0.9,
|
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 5),
|
const SizedBox(height: 3),
|
||||||
text(routine.title, 15, TextType.Medium,
|
text(routine.title, 15, TextType.Medium,
|
||||||
color: colorPrimaryDark,
|
color: colorInk,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis),
|
overflow: TextOverflow.ellipsis),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
@@ -586,6 +673,7 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
|
|||||||
: ProgressRing(
|
: ProgressRing(
|
||||||
fraction: routine.partialCompletion,
|
fraction: routine.partialCompletion,
|
||||||
fill: tone,
|
fill: tone,
|
||||||
|
track: tone.withValues(alpha: 0.12),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -610,98 +698,162 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
|
|||||||
return Row(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, size: 12, color: colorGrey2),
|
Icon(icon, size: 12, color: colorInkSoft),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
text(value, 11, TextType.Regular, color: colorGrey2),
|
text(value, 11, TextType.Regular, color: colorInkSoft),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The preview: the whole chain laid out before you commit to starting it.
|
/// The preview: the scene up top, then the whole chain laid out before you
|
||||||
|
/// commit to starting it.
|
||||||
Widget _previewSheet(BuildContext sheetContext, RoutineChain routine) {
|
Widget _previewSheet(BuildContext sheetContext, RoutineChain routine) {
|
||||||
final Color tone = kindColor(routine.kind);
|
final Color tone = kindColor(routine.kind);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
maxHeight: MediaQuery.of(sheetContext).size.height * 0.86,
|
maxHeight: MediaQuery.of(sheetContext).size.height * 0.9,
|
||||||
),
|
),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: colorSheetBackground,
|
color: colorAmbientMid,
|
||||||
borderRadius: BorderRadius.only(
|
borderRadius: BorderRadius.only(
|
||||||
topLeft: Radius.circular(28),
|
topLeft: Radius.circular(32),
|
||||||
topRight: Radius.circular(28),
|
topRight: Radius.circular(32),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Center(
|
Flexible(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: 220,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
SceneArt(
|
||||||
|
palette: scenePalette(routine.kind),
|
||||||
|
seed: sceneSeed(routine.title),
|
||||||
|
figure: kindIcon(routine.kind),
|
||||||
|
figureScale: 0.5,
|
||||||
|
figureAlignment: const Alignment(0.4, -0.1),
|
||||||
|
shade: true,
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 10,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Center(
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 38,
|
width: 38,
|
||||||
height: 4,
|
height: 4,
|
||||||
margin: const EdgeInsets.only(top: 10),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorGrey.withValues(alpha: 0.5),
|
color: colorWhite.withValues(alpha: 0.7),
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Flexible(
|
),
|
||||||
child: SingleChildScrollView(
|
Positioned(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 20, 24, 12),
|
left: 24,
|
||||||
|
right: 24,
|
||||||
|
bottom: 18,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
pill(kindLabel(routine.kind), tone,
|
GlassSurface(
|
||||||
kindBackground(routine.kind),
|
onDark: true,
|
||||||
icon: kindIcon(routine.kind), textSize: 9),
|
radius: BorderRadius.circular(99),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10, vertical: 5),
|
||||||
|
child: text(kindLabel(routine.kind), 10,
|
||||||
|
TextType.Medium,
|
||||||
|
color: colorWhite),
|
||||||
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
pill(classLabel(routine.commitmentClass),
|
GlassSurface(
|
||||||
classColor(routine.commitmentClass),
|
onDark: true,
|
||||||
classBackground(routine.commitmentClass),
|
radius: BorderRadius.circular(99),
|
||||||
textSize: 9),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10, vertical: 5),
|
||||||
|
child: text(
|
||||||
|
classLabel(routine.commitmentClass),
|
||||||
|
10,
|
||||||
|
TextType.Medium,
|
||||||
|
color: colorWhite),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 10),
|
||||||
displayTitle(routine.title, size: 30),
|
text(routine.title, 28, TextType.Medium,
|
||||||
if (routine.note.isNotEmpty) ...[
|
color: colorWhite,
|
||||||
const SizedBox(height: 8),
|
height: 1.1,
|
||||||
text(routine.note, 13, TextType.Regular,
|
letterSpacing: -0.6),
|
||||||
color: colorGrey2, height: 1.5),
|
|
||||||
],
|
],
|
||||||
const SizedBox(height: 20),
|
),
|
||||||
Row(
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 18, 24, 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (routine.note.isNotEmpty) ...[
|
||||||
|
text(routine.note, 13, TextType.Regular,
|
||||||
|
color: colorInkSoft, height: 1.5),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
GlassSurface(
|
||||||
|
radius: BorderRadius.circular(22),
|
||||||
|
tint: colorGlassStrong,
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
||||||
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: statTile("Minutes",
|
child: _stripStat(
|
||||||
"${(routine.totalSeconds / 60).round()}")),
|
CupertinoIcons.clock,
|
||||||
|
"${(routine.totalSeconds / 60).round()}",
|
||||||
|
"Minutes"),
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: statTile("Steps", "${routine.steps.length}")),
|
child: _stripStat(CupertinoIcons.list_bullet,
|
||||||
|
"${routine.steps.length}", "Steps"),
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: statTile(
|
child: _stripStat(
|
||||||
"When",
|
CupertinoIcons.alarm,
|
||||||
routine.startMinute < 0
|
routine.startMinute < 0
|
||||||
? "Any"
|
? "Any"
|
||||||
: formatStartMinute(routine.startMinute),
|
: formatStartMinute(
|
||||||
caption: formatDays(routine.days),
|
routine.startMinute),
|
||||||
),
|
formatDays(routine.days)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
),
|
||||||
|
const SizedBox(height: 22),
|
||||||
sectionBreak("The chain",
|
sectionBreak("The chain",
|
||||||
caption: routine.kind == RoutineKind.Workout
|
caption: routine.kind == RoutineKind.Workout
|
||||||
? "${routine.totalSets} sets, in order"
|
? "${routine.totalSets} sets, in order"
|
||||||
: "${routine.steps.length} steps, in order"),
|
: "${routine.steps.length} steps, in order"),
|
||||||
...routine.steps
|
...routine.steps.asMap().entries.map((entry) =>
|
||||||
.asMap()
|
_previewStep(entry.key, entry.value, tone)),
|
||||||
.entries
|
],
|
||||||
.map((entry) => _previewStep(entry.key, entry.value, tone)),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -738,20 +890,24 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _previewStep(int index, RoutineStep step, Color tone) {
|
Widget _previewStep(int index, RoutineStep step, Color tone) {
|
||||||
return Padding(
|
return Container(
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
padding: const EdgeInsets.fromLTRB(10, 10, 14, 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorWhite,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 28,
|
width: 34,
|
||||||
height: 28,
|
height: 34,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: tone.withValues(alpha: 0.10),
|
color: tone.withValues(alpha: 0.10),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: text("${index + 1}", 11, TextType.Bold, color: tone),
|
child: text("${index + 1}", 12, TextType.Medium, color: tone),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -759,18 +915,14 @@ class RoutinesState extends State<Routines> implements ConnectRoutines {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 4),
|
text(step.title, 14, TextType.Medium, color: colorInk),
|
||||||
text(step.title, 14, TextType.Medium, color: colorPrimaryDark),
|
|
||||||
const SizedBox(height: 3),
|
const SizedBox(height: 3),
|
||||||
text(stepSummary(step), 11, TextType.Regular,
|
text(stepSummary(step), 11, TextType.Regular,
|
||||||
color: colorGrey2),
|
color: colorInkSoft),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
Icon(stepModeIcon(step.mode), size: 15, color: colorInkSoft),
|
||||||
padding: const EdgeInsets.only(top: 5),
|
|
||||||
child: Icon(stepModeIcon(step.mode), size: 15, color: colorGrey),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
import 'package:stacked/stacked.dart';
|
import 'package:stacked/stacked.dart';
|
||||||
|
|
||||||
import '../../about/internal/application/MeDescription.dart';
|
import '../../about/internal/application/MeDescription.dart';
|
||||||
import '../../about/internal/application/NavigatorType.dart';
|
import '../../about/internal/application/NavigatorType.dart';
|
||||||
import '../../about/internal/application/TextType.dart';
|
import '../../about/internal/application/TextType.dart';
|
||||||
import '../../configs/Navigator.dart';
|
import '../../configs/Navigator.dart';
|
||||||
|
import '../../designs/AmbientBackground.dart';
|
||||||
|
import '../../designs/GlassSurface.dart';
|
||||||
|
import '../../designs/GroundedMark.dart';
|
||||||
import '../../designs/Responsive.dart';
|
import '../../designs/Responsive.dart';
|
||||||
|
import '../../designs/scene/SceneArt.dart';
|
||||||
import '../../designs/text/Text.dart';
|
import '../../designs/text/Text.dart';
|
||||||
import '../../utils/Colors.dart';
|
import '../../utils/Colors.dart';
|
||||||
import '../../utils/Images.dart';
|
|
||||||
import '../home/Home.dart';
|
import '../home/Home.dart';
|
||||||
import '../login/Login.dart';
|
import '../login/Login.dart';
|
||||||
import 'ConnectSplash.dart';
|
import 'ConnectSplash.dart';
|
||||||
@@ -31,7 +35,7 @@ class SplashState extends State<Splash> implements ConnectSplash {
|
|||||||
builder: (context, viewModel, child) => PopScope(
|
builder: (context, viewModel, child) => PopScope(
|
||||||
canPop: false,
|
canPop: false,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: colorPrimaryDark,
|
backgroundColor: colorAmbientMid,
|
||||||
body: LayoutBuilder(
|
body: LayoutBuilder(
|
||||||
builder: (BuildContext context, BoxConstraints constraints) {
|
builder: (BuildContext context, BoxConstraints constraints) {
|
||||||
return Responsive(
|
return Responsive(
|
||||||
@@ -47,69 +51,193 @@ class SplashState extends State<Splash> implements ConnectSplash {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initiate() async {
|
Future<void> _initiate() async {
|
||||||
// A beat on the wordmark, then the session decides where we land.
|
// Long enough for the mark to land and take one breath, then the session
|
||||||
await Future.delayed(const Duration(milliseconds: 900));
|
// decides where we land.
|
||||||
|
await Future.delayed(const Duration(milliseconds: 2200));
|
||||||
_model?.initialize();
|
_model?.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _mobileView(BoxConstraints constraints) {
|
Widget _mobileView(BoxConstraints constraints) {
|
||||||
return SafeArea(
|
return AmbientBackground(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
// The landscape rising from the bottom edge — the same painted world
|
||||||
|
// the routine cards live in.
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: constraints.maxHeight * 0.34,
|
||||||
|
child: ShaderMask(
|
||||||
|
shaderCallback: (Rect bounds) => const LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: <Color>[Color(0x00000000), Color(0xFF000000)],
|
||||||
|
stops: <double>[0.0, 0.45],
|
||||||
|
).createShader(bounds),
|
||||||
|
blendMode: BlendMode.dstIn,
|
||||||
|
child: const SceneArt(palette: sceneWorkout, seed: 11),
|
||||||
|
)
|
||||||
|
.animate()
|
||||||
|
.fadeIn(duration: 1200.ms, curve: Curves.easeOut)
|
||||||
|
.slideY(begin: 0.25, end: 0, duration: 1400.ms,
|
||||||
|
curve: Curves.easeOutCubic),
|
||||||
|
),
|
||||||
|
SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 36),
|
padding: const EdgeInsets.fromLTRB(28, 18, 28, 32),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.max,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
children: [
|
||||||
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
text("GROUNDED", 10, TextType.Bold,
|
text("Grounded", 17, TextType.Regular, color: colorInk),
|
||||||
color: colorWhite.withValues(alpha: 0.45), letterSpacing: 2.0),
|
text("Daily discipline", 17, TextType.Regular,
|
||||||
Column(
|
color: colorInkSoft),
|
||||||
mainAxisSize: MainAxisSize.min,
|
],
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
).animate().fadeIn(duration: 600.ms, delay: 200.ms),
|
||||||
children: [
|
const Spacer(flex: 3),
|
||||||
// The same mark the native splash shows, so the handover from
|
Center(child: _mark()),
|
||||||
// the OS screen into the app is invisible.
|
const SizedBox(height: 40),
|
||||||
Image.asset(
|
_wordmark(),
|
||||||
splashMark,
|
const Spacer(flex: 4),
|
||||||
width: 108,
|
_loader(),
|
||||||
height: 108,
|
|
||||||
errorBuilder: (context, error, stackTrace) =>
|
|
||||||
const SizedBox(height: 108),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 28),
|
|
||||||
text("Grounded", 52, TextType.Light,
|
|
||||||
color: colorWhite, height: 1.05),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Container(
|
|
||||||
width: 44,
|
|
||||||
height: 2,
|
|
||||||
color: colorWhite.withValues(alpha: 0.30),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
text(
|
|
||||||
"A to-do app that does not believe you.",
|
|
||||||
15,
|
|
||||||
TextType.Regular,
|
|
||||||
color: colorWhite.withValues(alpha: 0.60),
|
|
||||||
height: 1.5,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
SizedBox(
|
|
||||||
width: 22,
|
|
||||||
height: 22,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
backgroundColor: colorWhite.withValues(alpha: 0.12),
|
|
||||||
color: colorWhite.withValues(alpha: 0.70),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The mark on glass, inside rings that breathe outward.
|
||||||
|
Widget _mark() {
|
||||||
|
return SizedBox(
|
||||||
|
width: 230,
|
||||||
|
height: 230,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
for (int ring = 0; ring < 3; ring++)
|
||||||
|
Container(
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: colorWhite.withValues(alpha: 0.9), width: 1.4),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.animate(onPlay: (controller) => controller.repeat())
|
||||||
|
.scale(
|
||||||
|
begin: const Offset(1, 1),
|
||||||
|
end: const Offset(1.9, 1.9),
|
||||||
|
duration: 2400.ms,
|
||||||
|
delay: (ring * 800).ms,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
)
|
||||||
|
.fadeOut(duration: 2400.ms, delay: (ring * 800).ms),
|
||||||
|
GlassSurface(
|
||||||
|
radius: BorderRadius.circular(44),
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(32),
|
||||||
|
boxShadow: <BoxShadow>[
|
||||||
|
BoxShadow(
|
||||||
|
color: colorPrimary.withValues(alpha: 0.35),
|
||||||
|
blurRadius: 40,
|
||||||
|
offset: const Offset(0, 16),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// The sun rises out of the ground line, then the arcs light.
|
||||||
|
child: TweenAnimationBuilder<double>(
|
||||||
|
tween: Tween<double>(begin: 0, end: 1),
|
||||||
|
duration: const Duration(milliseconds: 1700),
|
||||||
|
builder: (context, t, child) => GroundedMark(
|
||||||
|
size: 132,
|
||||||
|
rise: Curves.easeOutCubic
|
||||||
|
.transform((t / 0.6).clamp(0.0, 1.0)),
|
||||||
|
arcs: Curves.easeOut
|
||||||
|
.transform(((t - 0.45) / 0.55).clamp(0.0, 1.0)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.animate()
|
||||||
|
.scale(
|
||||||
|
begin: const Offset(0.6, 0.6),
|
||||||
|
end: const Offset(1, 1),
|
||||||
|
duration: 900.ms,
|
||||||
|
curve: Curves.easeOutBack,
|
||||||
|
)
|
||||||
|
.fadeIn(duration: 500.ms),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two-tone, like the greeting on the home screen.
|
||||||
|
Widget _wordmark() {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
text("Grounded", 46, TextType.Medium,
|
||||||
|
color: colorInk, align: TextAlign.center, letterSpacing: -1.2)
|
||||||
|
.animate()
|
||||||
|
.fadeIn(duration: 700.ms, delay: 350.ms)
|
||||||
|
.slideY(begin: 0.3, end: 0, duration: 700.ms, delay: 350.ms,
|
||||||
|
curve: Curves.easeOutCubic),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
text("A to-do app that\ndoesn't believe you.", 22, TextType.Light,
|
||||||
|
color: colorInkSoft, align: TextAlign.center, height: 1.3)
|
||||||
|
.animate()
|
||||||
|
.fadeIn(duration: 700.ms, delay: 650.ms)
|
||||||
|
.slideY(begin: 0.3, end: 0, duration: 700.ms, delay: 650.ms,
|
||||||
|
curve: Curves.easeOutCubic),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A glass capsule that fills while the session resolves.
|
||||||
|
Widget _loader() {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
GlassSurface(
|
||||||
|
radius: BorderRadius.circular(99),
|
||||||
|
padding: const EdgeInsets.all(3),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 140,
|
||||||
|
height: 6,
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Container(
|
||||||
|
width: 140,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorNight,
|
||||||
|
borderRadius: BorderRadius.circular(99),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.animate()
|
||||||
|
.scaleX(
|
||||||
|
begin: 0.04,
|
||||||
|
end: 1,
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
duration: 2000.ms,
|
||||||
|
curve: Curves.easeInOutCubic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
text("Checking what you promised", 12, TextType.Regular,
|
||||||
|
color: colorWhite.withValues(alpha: 0.9)),
|
||||||
|
],
|
||||||
|
).animate().fadeIn(duration: 500.ms, delay: 900.ms);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void launchHome(MeDescription value) {
|
void launchHome(MeDescription value) {
|
||||||
GroundedNavigation()
|
GroundedNavigation()
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 390 B After Width: | Height: | Size: 388 B |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 20 KiB |
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 9.3 KiB |