Redesign the UI after the fitness reference and replace the logo
Light sage-to-cream ambient backdrop, frosted glass controls and painted SceneArt landscapes in place of photography. Home becomes a dashboard with a two-tone greeting, a frosted summary ring, category portraits, an up-next rail of image-led cards and a floating glass tab bar. The routine library gets an image header and painted cards; the player gets a scene stage, steps on an arc, a dotted clock and a night pill with a lime spark. Goals become painted cards in their own colour. New logo: a lime sun resting on a ground line under two arcs. The splash raises the sun and lights the arcs; launcher icons and native splash are regenerated to match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
This commit is contained in:
61
frontend/lib/Grounded/designs/AmbientBackground.dart
Normal file
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
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,
|
||||
Color? background,
|
||||
Color? borderColor,
|
||||
double radius = 16,
|
||||
double radius = 22,
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
final Widget body = Container(
|
||||
@@ -71,12 +71,12 @@ Widget card({
|
||||
decoration: BoxDecoration(
|
||||
color: background ?? colorCard,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
border: Border.all(color: borderColor ?? colorBorder, width: 1),
|
||||
border: Border.all(color: borderColor ?? colorGlassBorder, width: 1),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x08000000),
|
||||
blurRadius: 18,
|
||||
offset: Offset(0, 4),
|
||||
color: Color(0x0F2A3A30),
|
||||
blurRadius: 24,
|
||||
offset: Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
175
frontend/lib/Grounded/designs/DotDigits.dart
Normal file
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
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
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
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 '../about/internal/application/TextType.dart';
|
||||
import '../utils/Colors.dart';
|
||||
import 'Component.dart';
|
||||
import 'AmbientBackground.dart';
|
||||
import 'ChromeTone.dart';
|
||||
import 'GlassSurface.dart';
|
||||
import 'scene/SceneArt.dart';
|
||||
import 'text/Text.dart';
|
||||
|
||||
/// The house layout: black chrome at the top, a white sheet rising into it with
|
||||
/// a large corner radius. Every screen is built from this so the app reads as
|
||||
/// one object rather than a stack of pages.
|
||||
/// The house layout. On the default ambient tone the screen floats on sage
|
||||
/// mist with frosted controls and ink type, and the content rises on a frosted
|
||||
/// sheet. Screens that carry a colour — a goal, a standing tier — or a banner
|
||||
/// get a tinted stage instead, with white type and smoky glass.
|
||||
class Sheet extends StatelessWidget {
|
||||
/// Small all-caps label rendered in the black chrome, above the title.
|
||||
/// Small all-caps label rendered in the chrome, above the title.
|
||||
final String eyebrow;
|
||||
|
||||
/// The chrome title — small and centred, not the display title.
|
||||
@@ -22,7 +27,7 @@ class Sheet extends StatelessWidget {
|
||||
/// Optional trailing control in the chrome.
|
||||
final Widget? action;
|
||||
|
||||
/// Chrome colour. Defaults to near-black; standing screens tint it.
|
||||
/// Stage colour. Leaving it null (with no banner) gives the ambient tone.
|
||||
final Color? chrome;
|
||||
|
||||
/// Rendered inside the chrome beneath the title — the standing strip.
|
||||
@@ -30,6 +35,12 @@ class Sheet extends StatelessWidget {
|
||||
|
||||
final bool scrollable;
|
||||
|
||||
/// A painted landscape behind a tinted stage's header, in place of the
|
||||
/// flat gradient.
|
||||
final List<Color>? scene;
|
||||
|
||||
final int sceneSeed;
|
||||
|
||||
const Sheet({
|
||||
super.key,
|
||||
required this.title,
|
||||
@@ -40,158 +51,207 @@ class Sheet extends StatelessWidget {
|
||||
this.chrome,
|
||||
this.banner,
|
||||
this.scrollable = true,
|
||||
this.scene,
|
||||
this.sceneSeed = 0,
|
||||
});
|
||||
|
||||
bool get _light => chrome == null && banner == null && scene == null;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color chromeColor = chrome ?? colorPrimaryDark;
|
||||
final bool light = _light;
|
||||
final Color stage = chrome ?? colorPrimaryDark;
|
||||
final Color ink = light ? colorInk : colorWhite;
|
||||
final Color soft =
|
||||
light ? colorInkSoft : colorWhite.withValues(alpha: 0.5);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: chromeColor,
|
||||
body: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 44,
|
||||
child: onBack == null
|
||||
? null
|
||||
: _chromeButton(
|
||||
Icons.arrow_back_ios_new_rounded, onBack!),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (eyebrow.isNotEmpty) ...[
|
||||
text(
|
||||
eyebrow.toUpperCase(),
|
||||
9,
|
||||
TextType.Bold,
|
||||
color: colorWhite.withValues(alpha: 0.45),
|
||||
letterSpacing: 1.2,
|
||||
align: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
],
|
||||
text(
|
||||
title,
|
||||
15,
|
||||
TextType.Medium,
|
||||
color: colorWhite,
|
||||
align: TextAlign.center,
|
||||
),
|
||||
],
|
||||
final Widget header = SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 18),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 46,
|
||||
child: onBack == null
|
||||
? null
|
||||
: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: glassCircle(
|
||||
CupertinoIcons.arrow_left, onBack!,
|
||||
onDark: !light),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 44,
|
||||
child: action == null
|
||||
? null
|
||||
: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: action,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (eyebrow.isNotEmpty) ...[
|
||||
text(
|
||||
eyebrow.toUpperCase(),
|
||||
9,
|
||||
TextType.Bold,
|
||||
color: soft,
|
||||
letterSpacing: 1.2,
|
||||
align: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
],
|
||||
text(
|
||||
title.toUpperCase(),
|
||||
14,
|
||||
TextType.Medium,
|
||||
color: ink,
|
||||
letterSpacing: 0.6,
|
||||
align: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (banner != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
banner!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorPrimaryLight,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(28),
|
||||
topRight: Radius.circular(28),
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: scrollable
|
||||
? SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 28, 20, 40),
|
||||
child: child,
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 28, 20, 0),
|
||||
child: child,
|
||||
),
|
||||
SizedBox(
|
||||
width: 46,
|
||||
child: action == null
|
||||
? null
|
||||
: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: action,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
if (banner != null) ...[
|
||||
const SizedBox(height: 18),
|
||||
banner!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chromeButton(IconData icon, VoidCallback onTap) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
final Widget body = Expanded(
|
||||
child: Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite.withValues(alpha: 0.10),
|
||||
shape: BoxShape.circle,
|
||||
color: light ? colorFrost : colorPrimaryLight,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(32),
|
||||
topRight: Radius.circular(32),
|
||||
),
|
||||
),
|
||||
child: Icon(icon, size: 15, color: colorWhite),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: scrollable
|
||||
? SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 28, 20, 40),
|
||||
child: child,
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 28, 20, 0),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final Widget column = ChromeTone(
|
||||
light: light,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [header, body],
|
||||
),
|
||||
);
|
||||
|
||||
if (light) {
|
||||
return Scaffold(
|
||||
backgroundColor: colorAmbientMid,
|
||||
body: AmbientBackground(child: column),
|
||||
);
|
||||
}
|
||||
|
||||
// A tinted stage: the colour deepens downward and a soft light blooms in
|
||||
// the corner, so it reads as lit rather than painted flat.
|
||||
return Scaffold(
|
||||
backgroundColor: stage,
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: <Color>[
|
||||
Color.lerp(stage, colorWhite, 0.14)!,
|
||||
stage,
|
||||
Color.lerp(stage, colorBlack, 0.35)!,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (scene != null)
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 250,
|
||||
child: ShaderMask(
|
||||
shaderCallback: (Rect bounds) => const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: <Color>[Color(0xFF000000), Color(0x00000000)],
|
||||
stops: <double>[0.6, 1.0],
|
||||
).createShader(bounds),
|
||||
blendMode: BlendMode.dstIn,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
SceneArt(palette: scene!, seed: sceneSeed),
|
||||
ColoredBox(color: colorBlack.withValues(alpha: 0.42)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: -120,
|
||||
right: -90,
|
||||
child: Container(
|
||||
width: 300,
|
||||
height: 300,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: <Color>[
|
||||
colorWhite.withValues(alpha: 0.16),
|
||||
colorWhite.withValues(alpha: 0.0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(child: column),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A circular control for the black chrome, with an optional unread dot.
|
||||
/// A round frosted control for the chrome, with an optional unread dot. It
|
||||
/// matches the stage it sits on.
|
||||
Widget chromeAction(
|
||||
IconData icon,
|
||||
VoidCallback onTap, {
|
||||
bool dotted = false,
|
||||
Color? dotColor,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: colorWhite.withValues(alpha: 0.10),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, size: 17, color: colorWhite),
|
||||
),
|
||||
if (dotted)
|
||||
Positioned(
|
||||
top: 1,
|
||||
right: 1,
|
||||
child: Container(
|
||||
width: 9,
|
||||
height: 9,
|
||||
decoration: BoxDecoration(
|
||||
color: dotColor ?? colorAccent,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: colorPrimaryDark, width: 1.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
return Builder(
|
||||
builder: (BuildContext context) => glassCircle(
|
||||
icon,
|
||||
onTap,
|
||||
onDark: !ChromeTone.isLight(context),
|
||||
dotted: dotted,
|
||||
dotColor: dotColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -215,12 +275,11 @@ Widget labelled(
|
||||
label.toUpperCase(),
|
||||
9,
|
||||
TextType.Bold,
|
||||
color: labelColor ?? colorGrey2,
|
||||
color: labelColor ?? colorInkSoft,
|
||||
letterSpacing: 1.0,
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
text(value, valueSize, valueType,
|
||||
color: valueColor ?? colorPrimaryDark),
|
||||
text(value, valueSize, valueType, color: valueColor ?? colorInk),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -247,41 +306,40 @@ Widget metaRow(List<Widget> items) {
|
||||
/// The oversized light display title that opens a section.
|
||||
Widget displayTitle(String value, {Color? color, double size = 34}) {
|
||||
return text(value, size, TextType.Light,
|
||||
color: color ?? colorPrimaryDark, height: 1.15);
|
||||
color: color ?? colorInk, height: 1.15);
|
||||
}
|
||||
|
||||
/// A section break: hairline, then a small bold heading with an optional
|
||||
/// trailing chip.
|
||||
/// A section heading: medium ink label, a soft caption beside it and an
|
||||
/// optional trailing control ("View all").
|
||||
Widget sectionBreak(String heading, {Widget? trailing, String caption = ""}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
hairline(margin: const EdgeInsets.only(bottom: 20)),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
text(heading, 17, TextType.Bold, color: colorPrimaryDark),
|
||||
if (caption.isNotEmpty) ...[
|
||||
const SizedBox(width: 10),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 6, bottom: 14),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
text(heading, 16, TextType.Medium, color: colorInk),
|
||||
if (caption.isNotEmpty) ...[
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 1),
|
||||
child: text(caption, 11, TextType.Regular,
|
||||
color: colorGrey2),
|
||||
color: colorInkSoft,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (trailing != null) trailing,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
if (trailing != null) trailing,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,10 +13,10 @@ Widget roundedCornerButton(
|
||||
Color? foreground,
|
||||
IconData? icon,
|
||||
bool enabled = true,
|
||||
double radius = 14,
|
||||
double radius = 99,
|
||||
double verticalPadding = 16,
|
||||
}) {
|
||||
final Color bg = enabled ? (background ?? colorPrimaryDark) : colorGrey;
|
||||
final Color bg = enabled ? (background ?? colorNight) : colorGrey;
|
||||
final Color fg = foreground ?? colorWhite;
|
||||
|
||||
return ElevatedButton(
|
||||
@@ -35,10 +35,13 @@ Widget roundedCornerButton(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 16, color: fg),
|
||||
// The default night pill carries the lime spark on its icon.
|
||||
Icon(icon,
|
||||
size: 16,
|
||||
color: background == null && foreground == null ? colorLime : fg),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
text(label, 14, TextType.Bold, color: fg),
|
||||
text(label, 14, TextType.Medium, color: fg),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -52,7 +55,7 @@ Widget outlinedActionButton(
|
||||
Color? foreground,
|
||||
IconData? icon,
|
||||
bool enabled = true,
|
||||
double radius = 14,
|
||||
double radius = 99,
|
||||
}) {
|
||||
final Color fg = enabled ? (foreground ?? colorPrimaryDark) : colorGrey;
|
||||
|
||||
|
||||
114
frontend/lib/Grounded/designs/scene/SceneArt.dart
Normal file
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
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user