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 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, ), ), ), ], ), ); } }