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
346 lines
10 KiB
Dart
346 lines
10 KiB
Dart
import 'package:flutter/cupertino.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../about/internal/application/TextType.dart';
|
|
import '../utils/Colors.dart';
|
|
import 'AmbientBackground.dart';
|
|
import 'ChromeTone.dart';
|
|
import 'GlassSurface.dart';
|
|
import 'scene/SceneArt.dart';
|
|
import 'text/Text.dart';
|
|
|
|
/// 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 chrome, above the title.
|
|
final String eyebrow;
|
|
|
|
/// The chrome title — small and centred, not the display title.
|
|
final String title;
|
|
|
|
final Widget child;
|
|
|
|
final VoidCallback? onBack;
|
|
|
|
/// Optional trailing control in the chrome.
|
|
final Widget? action;
|
|
|
|
/// 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.
|
|
final Widget? banner;
|
|
|
|
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,
|
|
required this.child,
|
|
this.eyebrow = "",
|
|
this.onBack,
|
|
this.action,
|
|
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 bool light = _light;
|
|
final Color stage = chrome ?? colorPrimaryDark;
|
|
final Color ink = light ? colorInk : colorWhite;
|
|
final Color soft =
|
|
light ? colorInkSoft : colorWhite.withValues(alpha: 0.5);
|
|
|
|
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),
|
|
),
|
|
),
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 46,
|
|
child: action == null
|
|
? null
|
|
: Align(
|
|
alignment: Alignment.centerRight,
|
|
child: action,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (banner != null) ...[
|
|
const SizedBox(height: 18),
|
|
banner!,
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
final Widget body = Expanded(
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: light ? colorFrost : colorPrimaryLight,
|
|
borderRadius: const BorderRadius.only(
|
|
topLeft: Radius.circular(32),
|
|
topRight: Radius.circular(32),
|
|
),
|
|
),
|
|
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 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 Builder(
|
|
builder: (BuildContext context) => glassCircle(
|
|
icon,
|
|
onTap,
|
|
onDark: !ChromeTone.isLight(context),
|
|
dotted: dotted,
|
|
dotColor: dotColor,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// The label/value pair — a tiny grey all-caps label sitting directly above a
|
|
/// value. The core unit of the whole interface.
|
|
Widget labelled(
|
|
String label,
|
|
String value, {
|
|
double valueSize = 15,
|
|
TextType valueType = TextType.Medium,
|
|
Color? valueColor,
|
|
Color? labelColor,
|
|
CrossAxisAlignment align = CrossAxisAlignment.start,
|
|
}) {
|
|
return Column(
|
|
crossAxisAlignment: align,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
text(
|
|
label.toUpperCase(),
|
|
9,
|
|
TextType.Bold,
|
|
color: labelColor ?? colorInkSoft,
|
|
letterSpacing: 1.0,
|
|
),
|
|
const SizedBox(height: 5),
|
|
text(value, valueSize, valueType, color: valueColor ?? colorInk),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// A row of metadata above a display title, as on the reference: small grey
|
|
/// pairs separated by generous space.
|
|
Widget metaRow(List<Widget> items) {
|
|
final List<Widget> spaced = <Widget>[];
|
|
|
|
for (int index = 0; index < items.length; index++) {
|
|
spaced.add(items[index]);
|
|
if (index != items.length - 1) {
|
|
spaced.add(const SizedBox(width: 28));
|
|
}
|
|
}
|
|
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: spaced,
|
|
);
|
|
}
|
|
|
|
/// 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 ?? colorInk, height: 1.15);
|
|
}
|
|
|
|
/// 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 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: colorInkSoft,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
if (trailing != null) trailing,
|
|
],
|
|
),
|
|
);
|
|
}
|