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
304 lines
12 KiB
Dart
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|