Files
Garage-SDKs/garage_ui/lib/settings_list.dart
T
ImBenjiandClaude Opus 5.5 b269201919 The Garage SDKs, in the open
garage_auth, garage_entitlements, garage_iap and garage_ui, moved out of
Garage-Services and Metro-Map-Maker into one public repo. MIT, one readme,
docs under docs/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7
2026-09-23 18:49:21 +01:00

304 lines
12 KiB
Dart

import "package:flutter/widgets.dart";
import "field_error.dart";
import "overlay.dart" show Tooltip;
import "surface.dart" show Divider;
import "theme/garage_theme.dart";
/// A list of label / field pairs, ruled between entries.
///
/// The other way of showing a set of values is [PropertiesSection], which puts
/// them in a bordered, collapsible card. That card is a subject - a thing with
/// a name and a state you can fold away - and a page made of nothing but those
/// reads as a stack of boxes rather than a set of settings, which is the
/// complaint that produced this.
///
/// Here theres no box and no header. Just rows with a hairline between them,
/// so what you see is the values and where one ends and the next begins.
///
/// Not a scroller: it takes its height from its rows and expects a pane to do
/// the scrolling, the same as a run of sections does.
class SettingsList extends StatelessWidget {
const SettingsList({super.key, required this.children});
/// [SettingsRow]s, usually. Anything else is laid out and ruled the same.
final List<Widget> children;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final scheme = theme.colorScheme;
final inset = theme.density.gapSm;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < children.length; i++) ...[
// BETWEEN entries, not around them. A rule above the first row or
// under the last one draws a box, which is the thing this exists to
// not be.
//
// And the rule runs WIDER than the rows: the inset is on the rows,
// not on the list, so the line reaches slightly past the text at
// both ends. A rule that stops exactly where its content stops
// reads as the edge of a box; one that overshoots reads as a
// divider between two things.
if (i > 0) Divider(color: scheme.divider),
Padding(
padding: EdgeInsets.symmetric(horizontal: inset),
child: children[i],
),
],
],
);
}
}
/// One label and one field, on a line.
class SettingsRow extends StatelessWidget {
const SettingsRow({
super.key,
required this.label,
required this.field,
this.subtitle,
this.description,
this.error,
this.action,
this.labelTooltip,
});
/// Left, and it gets whatever width the field doesnt want.
final String label;
/// Shown on hovering the label, and only the label.
///
/// For the exact thing the label is a friendly name for - a permission
/// string, an id, a unit. That belongs somewhere you can go and look for
/// it rather than in the row, where it would be a second piece of text
/// competing with the one a person actually reads.
///
/// It wraps the label text itself, not its half of the row, so the empty
/// space beside a short label doesnt trigger anything.
final WidgetBuilder? labelTooltip;
/// A second line under the label, for the words the label had to leave out.
///
/// A QUALIFIER, not an explanation - "Account, passkeys and sign-in
/// activity" under "Read", not a sentence about what reading means. If it
/// needs a sentence it isnt a settings row.
final String? subtitle;
/// A full width line UNDER the whole row, label and field both.
///
/// For copy about the setting rather than about the field - consequences,
/// caveats, what changes when you change it. [subtitle] is a qualifier and
/// has to fit beside a value; this is a paragraph and doesnt.
///
/// The same slot [PropertyRow] has, for the same reason: a sentence squashed
/// into the label column is a sentence nobody reads.
final String? description;
/// Why the value was refused, under the row and in the destructive colour.
///
/// It also reddens the field's outline, through [FieldErrorScope] - so the
/// row says which one and this says why, which is the pair a toast cant be:
/// a message that floats over the corner of the screen has left the field
/// it was about behind.
final String? error;
/// Beside the label, on its line. The onboarding flow's ProductField has the
/// same slot and puts a muted "Optional" in it; a settings row gets one so
/// "Required" doesnt have to be found out by pressing Save.
///
/// Styled here rather than by the caller: it reads at the subtitle's size
/// and colour, so its a tag ON the label rather than a second label. A
/// caller that sets its own style still wins - this only supplies the
/// default.
final Widget? action;
/// Right, at its own size.
///
/// It is NOT stretched to a column: a Select that says "Engineering" should
/// be as wide as "Engineering", and a text box thats meant to be wide can
/// say so with a SizedBox. Stretching everything to one split is what makes
/// a form of mixed controls look like a table with a ragged edge.
final Widget field;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final density = theme.density;
// textXs over textXxs - the pairing density calls a "label column" in so
// many words, and the one the consent screen reads right at.
//
// The label was inheriting the ambient body size, which is the CONTROL
// font. Against a textXxs subtitle thats 1.0 to 0.875, near enough the
// same text twice, and the label stopped reading as the name of anything.
// A step up puts it at 1.1 to 0.875 and the two lines have different jobs
// again.
final labelStyle = theme.typography.normal.copyWith(
fontSize: density.textXs,
color: theme.colorScheme.foreground,
);
Widget labelText = Text(label, style: labelStyle);
if (labelTooltip case final tip?) {
labelText = Tooltip(tooltip: tip, child: labelText);
}
final aside = action;
if (aside != null) {
labelText = Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// flexible, so a long label ellipsises rather than shoving the tag
// off the end of the column
Flexible(child: labelText),
SizedBox(width: density.gapXs),
DefaultTextStyle.merge(
style: TextStyle(
fontSize: density.textXxs,
color: theme.colorScheme.mutedForeground,
fontWeight: FontWeight.normal,
),
child: aside,
),
],
);
}
final row = Padding(
padding: EdgeInsets.only(
top: density.gapMd,
bottom: description == null && error == null
? density.gapMd
: density.gapSm,
),
child: ConstrainedBox(
// A MINIMUM, and its the height of a row that HAS a subtitle.
//
// Otherwise a list where only some rows carry a second line comes out
// ragged - the plain ones close up to a single line box and the run
// of rows has two rhythms in it. labelColumnHeight is that stack
// exactly (textXs over textXxs on the font's own line box), plus the
// gap this row puts between the two.
//
// Rows that are legitimately taller - a four line text box - grow
// past it untouched, which a fixed height would squash.
constraints: BoxConstraints(
minHeight: density.labelColumnHeight + density.gapXxs,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
labelText,
if (subtitle != null) ...[
// the two lines were touching. A caption sat hard against
// the thing it captions reads as a wrapped second line of
// it rather than as a note under it.
SizedBox(height: density.gapXxs),
Text(
subtitle!,
style: TextStyle(
fontSize: density.textXxs,
color: theme.colorScheme.mutedForeground,
),
),
],
],
),
),
SizedBox(width: density.gapMd),
// Flexible, loose - so the field is BOUNDED but still sizes itself.
//
// A bare child in a Row is measured with an unbounded main axis,
// and anything with a flex child inside it - a ButtonGroup with
// `fill`, a Row of Expanded - asserts the moment it sees that. As a
// loose Flexible it gets "at most whats left", so a Text or a Select
// still shrink wraps and a filled control has a width to divide.
//
// Aligned right INSIDE that slot, because the slot is a share of the
// row rather than the width of the field. Without this a select that
// wants 90px sits at the left edge of its half and lands in the
// middle of the line, which is neither one column nor the other.
Flexible(
child: Align(
alignment: Alignment.centerRight,
child: FieldErrorScope(invalid: error != null, child: field),
),
),
],
),
),
);
final note = description;
final complaint = error;
// AnimatedSize even with nothing under the row: a complaint ARRIVING is
// the interesting case, and a row that only starts animating once it has
// something to animate would jump on the way in and ease on the way out.
//
// Its the row's own height thats moving, so it grows downward - anchored
// at the top, or every row above the one that was rejected shuffles.
//
// And ALWAYS the column under it, even when theres nothing in it but the
// row. Swapping between `row` and `Column(row, ...)` puts a different
// widget type at that position, so Flutter throws the subtree away and
// builds a new one - which takes the FIELD'S element with it. A brand
// new AnimatedOpacity starts AT its target, so the outline turned up
// already red however long it had been told to take getting there.
return AnimatedSize(
duration: kFieldErrorDuration,
curve: kFieldErrorCurve,
alignment: Alignment.topCenter,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
row,
// sat in the row's own bottom padding rather than under it, so the
// note belongs to the row above it and not to the rule below. Nudged
// up by the same amount it stands off the next one.
if (complaint != null)
Padding(
padding: EdgeInsets.only(
bottom: note == null ? density.gapMd : 0,
),
child: Text(
complaint,
style: TextStyle(
fontSize: density.textXxs,
color: theme.colorScheme.destructive,
),
),
),
if (note != null)
Padding(
padding: EdgeInsets.only(
top: complaint == null ? 0 : density.gapXxs,
bottom: density.gapMd,
),
child: Text(
note,
style: TextStyle(
fontSize: density.textXxs,
color: theme.colorScheme.mutedForeground,
),
),
),
],
),
);
}
}