Files
GroundedHelper/frontend/lib/Grounded/designs/Shell.dart
alvocool d3431ca5de Switch to Inter, round the corners softly and make the nav switch tabs
Inter replaces General Sans (Inter Display for titles and big numbers).
Corners come back from before the squaring commit, scaled to a soft set
(16 cards, 14 buttons, 12 rows, 8 chips) and applied to the screens built
since. The nav is now a cluster of round frosted buttons that switches
Today, Routines, Goals, Groups and Progress in place inside Home: no
pushed pages, no back arrows, and Android back returns to Today.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-09 07:08:46 +03:00

348 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;
/// Space under the content; tabs leave room for the floating nav.
final double bottomSpace;
/// 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,
this.bottomSpace = 40,
});
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(16),
topRight: Radius.circular(16),
),
),
clipBehavior: Clip.antiAlias,
child: scrollable
? SingleChildScrollView(
padding: EdgeInsets.fromLTRB(20, 28, 20, bottomSpace),
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,
],
),
);
}