Files
GroundedHelper/frontend/lib/Grounded/designs/Shell.dart
alvocool 9c858b8146 Square every corner, enlarge the main surfaces and add a to-do list
Rounded rectangles and round controls are now square across the app; only
status dots, glows, the sun, rings and the breathing circle stay round.
Cards, rows, thumbnails, featured cards, category tiles, the standing card
and the nav are larger. Home gets a quick To-do list kept on the device
(TodoItem in InternalMemory), seeded with sample items in mock mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-02 14:50:31 +03:00

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.zero,
topRight: Radius.zero,
),
),
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,
],
),
);
}