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
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
import "package:flutter/rendering.dart";
|
||||
import "package:flutter/widgets.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:garage_ui/garage_ui.dart";
|
||||
|
||||
import "support/test_scheme.dart";
|
||||
|
||||
// A centred label sat in an Expanded, so it centred in the space LEFT OVER
|
||||
// after a leading icon rather than across the button - one icon threw the text
|
||||
// off centre by half its own width plus the gap.
|
||||
void main() {
|
||||
Widget host(Widget child) => Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: GarageTheme(
|
||||
data: ThemeData(colorScheme: testScheme, density: const Density()),
|
||||
child: Center(child: SizedBox(width: 300, child: child)),
|
||||
),
|
||||
);
|
||||
|
||||
Future<double> offset(WidgetTester t, Widget b) async {
|
||||
await t.pumpWidget(host(b));
|
||||
return t.getRect(find.text("Label")).center.dx -
|
||||
t.getRect(find.byType(Button)).center.dx;
|
||||
}
|
||||
|
||||
Button btn({Widget? leading, Widget? trailing, AlignmentGeometry? alignment}) =>
|
||||
Button(
|
||||
style: const ButtonStyle.secondary(),
|
||||
alignment: alignment,
|
||||
leading: leading,
|
||||
trailing: trailing,
|
||||
onPressed: () {},
|
||||
child: const Text("Label"),
|
||||
);
|
||||
|
||||
const icon = Icon(LucideIcons.mail);
|
||||
|
||||
_fieldCentring();
|
||||
_labelNeverWraps();
|
||||
|
||||
testWidgets("centred label is centred with no icons", (t) async {
|
||||
expect(await offset(t, btn(alignment: Alignment.center)), 0.0);
|
||||
});
|
||||
|
||||
testWidgets("centred label is centred with a leading icon", (t) async {
|
||||
expect(await offset(t, btn(alignment: Alignment.center, leading: icon)), 0.0);
|
||||
});
|
||||
|
||||
testWidgets("centred label is centred with a trailing icon", (t) async {
|
||||
expect(await offset(t, btn(alignment: Alignment.center, trailing: icon)), 0.0);
|
||||
});
|
||||
|
||||
testWidgets("centred label is centred with both", (t) async {
|
||||
expect(
|
||||
await offset(t,
|
||||
btn(alignment: Alignment.center, leading: icon, trailing: icon)),
|
||||
0.0);
|
||||
});
|
||||
|
||||
testWidgets("no alignment means no balancing - the button still shrink-wraps",
|
||||
(t) async {
|
||||
// without an alignment there is no centre to miss, and padding the empty
|
||||
// side out would only make the button wider.
|
||||
// unconstrained, so the button reports its own intrinsic width
|
||||
Widget loose(Widget c) => Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: GarageTheme(
|
||||
data: ThemeData(colorScheme: testScheme, density: const Density()),
|
||||
child: Center(child: c),
|
||||
),
|
||||
);
|
||||
|
||||
await t.pumpWidget(loose(btn(leading: icon)));
|
||||
final unaligned = t.getSize(find.byType(Button)).width;
|
||||
await t.pumpWidget(loose(btn(alignment: Alignment.center, leading: icon)));
|
||||
final aligned = t.getSize(find.byType(Button)).width;
|
||||
|
||||
// the balanced one is wider by exactly the mirrored icon plus its gap
|
||||
expect(aligned, greaterThan(unaligned));
|
||||
expect(aligned - unaligned,
|
||||
const Density().iconSize + const Density().controlGap);
|
||||
});
|
||||
|
||||
testWidgets("an icon button with a leading icon isnt balanced out", (t) async {
|
||||
// there is no label in here to sit off centre - both slots are glyphs - so
|
||||
// the mirror spacer is 15px of nothing. the snap targets button in the
|
||||
// blender header measured 52 wide for 38 of content because of it.
|
||||
Widget loose(Widget c) => Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: GarageTheme(
|
||||
data: ThemeData(colorScheme: testScheme, density: const Density()),
|
||||
child: Center(child: c),
|
||||
),
|
||||
);
|
||||
|
||||
await t.pumpWidget(loose(IconButton.outline(
|
||||
density: ControlDensity.compact,
|
||||
leading: Icon(LucideIcons.ruler_dimension_line).iconSmall,
|
||||
icon: Icon(LucideIcons.chevron_down).iconSmall,
|
||||
onPressed: () {},
|
||||
)));
|
||||
|
||||
const d = Density();
|
||||
final pad = ControlDensity.compact
|
||||
.resolveIcon(ThemeData(colorScheme: testScheme, density: d));
|
||||
expect(
|
||||
t.getSize(find.byType(IconButton)).width,
|
||||
pad.horizontal + d.iconSize * 2 + d.controlGap,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// The same defect in TextField: centred text sits in an Expanded, so a leading
|
||||
// feature pushed it off the field's centre by half that feature plus the gap.
|
||||
void _fieldCentring() {
|
||||
Widget host(Widget child) => Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: GarageTheme(
|
||||
data: ThemeData(colorScheme: testScheme, density: const Density()),
|
||||
child: Center(child: SizedBox(width: 300, child: child)),
|
||||
),
|
||||
);
|
||||
|
||||
testWidgets("centred field text is centred with a leading feature",
|
||||
(t) async {
|
||||
await t.pumpWidget(host(const TextField(
|
||||
initialValue: "abc",
|
||||
readOnly: true,
|
||||
enabled: false,
|
||||
textAlign: TextAlign.center,
|
||||
features: [InputFeature.leading(Icon(LucideIcons.mail))],
|
||||
)));
|
||||
final field = t.getRect(find.byType(TextField));
|
||||
final text = t.getRect(find.text("abc"));
|
||||
expect((text.center.dx - field.center.dx).abs(), lessThan(0.5));
|
||||
});
|
||||
|
||||
testWidgets("balancing uses a spacer, not a second icon", (t) async {
|
||||
// a copy of the feature would sit in the tree twice, which is how this
|
||||
// broke a test that looked for exactly one icon inside a field.
|
||||
await t.pumpWidget(host(const TextField(
|
||||
initialValue: "abc",
|
||||
readOnly: true,
|
||||
enabled: false,
|
||||
textAlign: TextAlign.center,
|
||||
features: [InputFeature.leading(Icon(LucideIcons.mail))],
|
||||
)));
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(TextField), matching: find.byIcon(LucideIcons.mail)),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// A leading icon takes width off the label's allowance; Expanded forces the
|
||||
// label to absorb it, and Text used to resolve that by wrapping - "Use" on one
|
||||
// line and the rest on a second the button's height hid. It reads as a
|
||||
// truncation but never was one.
|
||||
void _labelNeverWraps() {
|
||||
Widget host(Widget child, double cap) => Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: GarageTheme(
|
||||
data: ThemeData(colorScheme: testScheme, density: const Density()),
|
||||
child: Center(child: SizedBox(width: cap, child: Wrap(children: [child]))),
|
||||
),
|
||||
);
|
||||
|
||||
for (final cap in [400.0, 130.0, 90.0]) {
|
||||
testWidgets("label stays on one line at width $cap", (t) async {
|
||||
await t.pumpWidget(host(
|
||||
Button(
|
||||
style: const ButtonStyle.primary(),
|
||||
leading: const Icon(LucideIcons.mail),
|
||||
onPressed: () {},
|
||||
child: const Text("Use passkey"),
|
||||
),
|
||||
cap,
|
||||
));
|
||||
final rp = t.renderObject(find.text("Use passkey")) as RenderParagraph;
|
||||
expect(rp.size.height, const Density().lineBox,
|
||||
reason: "a wrapped label is twice this tall");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import "package:flutter/widgets.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:garage_ui/garage_ui.dart";
|
||||
|
||||
import "support/test_scheme.dart";
|
||||
|
||||
// A segmented control lives in a PropertyRow: bounded WIDTH, unbounded height.
|
||||
// That combination is the whole point of these tests - `expands: true` drops
|
||||
// the IntrinsicHeight that gives `stretch` something to stretch to, and the
|
||||
// buttons collapse to nothing.
|
||||
Widget _host(Widget child) => Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: GarageTheme(
|
||||
data: ThemeData(colorScheme: testScheme, density: const Density()),
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: SizedBox(width: 300, child: child),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _seg(String label) => Button(
|
||||
style: const ButtonStyle.outline(),
|
||||
alignment: Alignment.center,
|
||||
onPressed: () {},
|
||||
child: Text(label),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets("fill splits the main axis evenly", (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
ButtonGroup.horizontal(
|
||||
fill: true,
|
||||
children: [_seg("Off"), _seg("Optional"), _seg("Required")],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
final off = tester.getSize(find.widgetWithText(Button, "Off"));
|
||||
final optional = tester.getSize(find.widgetWithText(Button, "Optional"));
|
||||
final required = tester.getSize(find.widgetWithText(Button, "Required"));
|
||||
|
||||
// an even share of the 300, not a share of the label lengths
|
||||
expect(off.width, closeTo(100, 1));
|
||||
expect(optional.width, closeTo(100, 1));
|
||||
expect(required.width, closeTo(100, 1));
|
||||
|
||||
// and a real height, which is what collapsed before
|
||||
expect(off.height, greaterThan(0));
|
||||
});
|
||||
|
||||
testWidgets("it survives a narrow field without overflowing", (
|
||||
tester,
|
||||
) async {
|
||||
// a phone-width property row. Three segments that each want ~90px of
|
||||
// label in 200px is where a fill would blow the right edge off.
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: GarageTheme(
|
||||
data: ThemeData(colorScheme: testScheme, density: const Density()),
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: SizedBox(
|
||||
width: 200,
|
||||
child: ButtonGroup.horizontal(
|
||||
fill: true,
|
||||
children: [_seg("Off"), _seg("Optional"), _seg("Required")],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
final group = tester.getSize(find.byType(ButtonGroup));
|
||||
expect(group.width, closeTo(200, 1));
|
||||
for (final label in ["Off", "Optional", "Required"]) {
|
||||
final seg = tester.getSize(find.widgetWithText(Button, label));
|
||||
expect(seg.width, closeTo(200 / 3, 1), reason: label);
|
||||
expect(seg.height, greaterThan(0), reason: label);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets("without fill it still shrink wraps to its labels", (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
ButtonGroup.horizontal(
|
||||
children: [_seg("Off"), _seg("Optional"), _seg("Required")],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
final off = tester.getSize(find.widgetWithText(Button, "Off"));
|
||||
final required = tester.getSize(find.widgetWithText(Button, "Required"));
|
||||
expect(off.width, lessThan(required.width));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import "package:flutter/widgets.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:garage_ui/garage_ui.dart";
|
||||
|
||||
import "support/test_scheme.dart";
|
||||
|
||||
// ChromeBar's height was a hardcoded 30 that didnt move with the tier. At
|
||||
// normal that left a 27px control 1.5px of margin, and at product the bar came
|
||||
// out SHORTER than the 33px buttons meant to sit in it - a header its own
|
||||
// contents didnt fit. It takes the density's token now.
|
||||
|
||||
Future<double> _barHeight(WidgetTester tester, Density density) async {
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: GarageTheme(
|
||||
data: ThemeData(colorScheme: testScheme, density: density),
|
||||
child: const Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ChromeBar(child: SizedBox.shrink()),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
return tester.getSize(find.byType(ChromeBar)).height;
|
||||
}
|
||||
|
||||
void main() {
|
||||
for (final density in const [
|
||||
Density(),
|
||||
Density.normal(),
|
||||
Density.product(),
|
||||
]) {
|
||||
final label = "${density.controlHeight.toInt()}px controls";
|
||||
|
||||
test("a chrome bar clears its own controls ($label)", () {
|
||||
// one controlGap above and one below - the same air a button gets from
|
||||
// the button beside it
|
||||
expect(
|
||||
density.chromeBarHeight,
|
||||
density.controlHeight + density.controlGap * 2,
|
||||
);
|
||||
|
||||
// the thing that was actually broken: the bar has to be able to hold
|
||||
// the control, not merely be near its size
|
||||
expect(
|
||||
density.chromeBarHeight,
|
||||
greaterThan(density.controlHeight),
|
||||
reason: "a control wouldnt fit inside its own chrome",
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("ChromeBar takes that height with none given ($label)", (
|
||||
tester,
|
||||
) async {
|
||||
expect(await _barHeight(tester, density), density.chromeBarHeight);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets("an explicit height still wins", (tester) async {
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: GarageTheme(
|
||||
data: ThemeData(
|
||||
colorScheme: testScheme,
|
||||
density: const Density.product(),
|
||||
),
|
||||
child: const Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ChromeBar(height: 52, child: SizedBox.shrink()),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(tester.getSize(find.byType(ChromeBar)).height, 52);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:garage_ui/theme/theme_data.dart";
|
||||
|
||||
// The gap scale is derived from controlGap, and the claim that makes that the
|
||||
// right base is that two tokens which were authored by hand long before the
|
||||
// scale existed land exactly on steps of it. If someone retunes controlGap,
|
||||
// containerGap or containerPadding and that stops being true, the base unit
|
||||
// has drifted and the scale is lying about where it comes from.
|
||||
void main() {
|
||||
for (final density in const [Density(), Density.normal()]) {
|
||||
final name = density.control.name;
|
||||
|
||||
test("$name: gapMd is containerGap, gapXl is containerPadding", () {
|
||||
expect(density.gapMd, density.containerGap);
|
||||
expect(density.gapXl, density.containerPadding);
|
||||
});
|
||||
|
||||
test("$name: the scale climbs and stays on whole pixels", () {
|
||||
final steps = [
|
||||
density.gapXxs,
|
||||
density.gapXs,
|
||||
density.gapSm,
|
||||
density.gapMd,
|
||||
density.gapLg,
|
||||
density.gapXl,
|
||||
density.gapXxl,
|
||||
];
|
||||
for (var i = 1; i < steps.length; i++) {
|
||||
expect(steps[i], greaterThan(steps[i - 1]),
|
||||
reason: "step $i should be bigger than the one before it");
|
||||
}
|
||||
for (final s in steps) {
|
||||
expect(s, s.roundToDouble(), reason: "$s is not a whole pixel");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("compact lands on the sizes the style guide observed in real use", () {
|
||||
const d = Density();
|
||||
expect([d.gapXxs, d.gapXs, d.gapSm, d.gapMd, d.gapLg, d.gapXl, d.gapXxl],
|
||||
[2.0, 4.0, 6.0, 8.0, 12.0, 16.0, 24.0]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import "package:flutter/widgets.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:garage_ui/garage_ui.dart";
|
||||
|
||||
import "support/test_scheme.dart";
|
||||
|
||||
Widget _host(Widget child) => Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: GarageTheme(
|
||||
data: ThemeData(colorScheme: testScheme),
|
||||
child: Center(child: SizedBox(width: 400, child: child)),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _row({String? error}) => _host(
|
||||
SettingsRow(
|
||||
key: const Key("row"),
|
||||
label: "Name",
|
||||
error: error,
|
||||
field: const TextField(),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets("the label's aside renders beside it, not instead of it", (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
const SettingsRow(
|
||||
label: "Name",
|
||||
action: Text("Required"),
|
||||
field: TextField(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text("Name"), findsOneWidget);
|
||||
expect(find.text("Required"), findsOneWidget);
|
||||
|
||||
// on the label's line, and at the FAR right of its half of the row -
|
||||
// spaceBetween, the way ProductField does it in the onboarding flow.
|
||||
final label = tester.getRect(find.text("Name"));
|
||||
final tag = tester.getRect(find.text("Required"));
|
||||
final field = tester.getRect(find.byType(TextField));
|
||||
expect((tag.center.dy - label.center.dy).abs(), lessThan(2));
|
||||
// tucked against the label rather than pushed to the far end of the
|
||||
// column - one gap between them, not the whole remaining width.
|
||||
expect(tag.left - label.right, lessThan(12));
|
||||
expect(tag.left, greaterThan(label.right - 1));
|
||||
expect(tag.right, lessThan(field.left));
|
||||
});
|
||||
|
||||
testWidgets("the aside reads as a tag, not as a second label", (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
const SettingsRow(
|
||||
label: "Name",
|
||||
action: Text("Required"),
|
||||
field: TextField(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final tag = tester.widget<Text>(find.text("Required"));
|
||||
final style = DefaultTextStyle.of(
|
||||
tester.element(find.text("Required")),
|
||||
).style.merge(tag.style);
|
||||
final label = tester.widget<Text>(find.text("Name"));
|
||||
|
||||
expect(style.fontSize, const Density().textXxs);
|
||||
expect(style.color, testScheme.mutedForeground);
|
||||
expect(style.fontSize, lessThan(label.style!.fontSize!));
|
||||
});
|
||||
|
||||
testWidgets("a property row puts it in the same place", (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
PropertyRow(
|
||||
label: "Price",
|
||||
scheme: testScheme,
|
||||
action: const Text("Required"),
|
||||
child: const TextField(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final label = tester.getRect(find.text("Price"));
|
||||
final tag = tester.getRect(find.text("Required"));
|
||||
final field = tester.getRect(find.byType(TextField));
|
||||
expect(tag.left - label.right, lessThan(12));
|
||||
expect(tag.right, lessThan(field.left));
|
||||
});
|
||||
|
||||
testWidgets("the row eases open rather than jumping", (tester) async {
|
||||
await tester.pumpWidget(_row());
|
||||
await tester.pumpAndSettle();
|
||||
final bare = tester.getSize(find.byKey(const Key("row"))).height;
|
||||
|
||||
await tester.pumpWidget(_row(error: "nope"));
|
||||
await tester.pump(); // the frame the change lands on
|
||||
|
||||
final atStart = tester.getSize(find.byKey(const Key("row"))).height;
|
||||
await tester.pump(const Duration(milliseconds: 90));
|
||||
final midway = tester.getSize(find.byKey(const Key("row"))).height;
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
final settled = tester.getSize(find.byKey(const Key("row"))).height;
|
||||
|
||||
expect(settled, greaterThan(bare));
|
||||
expect(midway, greaterThan(atStart));
|
||||
expect(midway, lessThan(settled));
|
||||
});
|
||||
|
||||
testWidgets("the outline fades in", (tester) async {
|
||||
await tester.pumpWidget(_row());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.pumpWidget(_row(error: "nope"));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 90));
|
||||
|
||||
final fades = tester
|
||||
.widgetList<FadeTransition>(find.byType(FadeTransition))
|
||||
.map((f) => f.opacity.value)
|
||||
.toList();
|
||||
expect(fades.any((v) => v > 0.0 && v < 1.0), isTrue);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import "package:flutter/widgets.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:garage_ui/garage_ui.dart";
|
||||
|
||||
import "support/test_scheme.dart";
|
||||
|
||||
// A feature lives INSIDE the field. It never gets to decide how tall the field
|
||||
// is - that is the density's job and nothing elses.
|
||||
//
|
||||
// It used to. The field wrapped its whole content row in textFieldPadding, so
|
||||
// anything you put in a feature slot got the padding stacked on top of its own
|
||||
// height. A trailing IconButton is a full control tall (23 at compact), so the
|
||||
// field came out at 23 + 12 = 35 against a controlHeight of 23 - half again as
|
||||
// tall as the plain field sat next to it. The packages OWN InputFeature.clear()
|
||||
// did it too, which is how it survived this long unnoticed.
|
||||
//
|
||||
// The existing feature-height probe in the app only ever passed a bare Text, so
|
||||
// none of this was covered.
|
||||
|
||||
Future<double> _height(
|
||||
WidgetTester tester, {
|
||||
required Density density,
|
||||
required List<InputFeature> features,
|
||||
String text = "",
|
||||
}) async {
|
||||
final controller = TextEditingController(text: text);
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: GarageTheme(
|
||||
data: ThemeData(colorScheme: testScheme, density: density),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 300,
|
||||
child: TextField(controller: controller, features: features),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
return tester.getSize(find.byType(TextField)).height;
|
||||
}
|
||||
|
||||
void main() {
|
||||
for (final density in const [Density(), Density.normal()]) {
|
||||
final label = density.control.name;
|
||||
|
||||
testWidgets("a trailing button doesnt grow the field ($label)", (
|
||||
tester,
|
||||
) async {
|
||||
final plain = await _height(tester, density: density, features: const []);
|
||||
final withButton = await _height(
|
||||
tester,
|
||||
density: density,
|
||||
features: [
|
||||
InputFeature.trailing(
|
||||
IconButton(
|
||||
variance: ButtonVariance.ghost,
|
||||
density: ControlDensity.compact,
|
||||
icon: const Icon(LucideIcons.eye),
|
||||
onPressed: () {},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
expect(plain, density.controlHeight);
|
||||
expect(
|
||||
withButton,
|
||||
plain,
|
||||
reason:
|
||||
"a button in a trailing slot pushed the field to $withButton "
|
||||
"against a plain $plain",
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("every built-in feature holds controlHeight ($label)", (
|
||||
tester,
|
||||
) async {
|
||||
final cases = <String, List<InputFeature>>{
|
||||
"leading icon": const [InputFeature.leading(Icon(LucideIcons.lock))],
|
||||
"clear": [const InputFeature.clear()],
|
||||
"spinner": [const InputFeature.spinner()],
|
||||
"increment": [const InputFeature.incrementButton()],
|
||||
"scrub": [const InputFeature.scrub()],
|
||||
};
|
||||
|
||||
for (final entry in cases.entries) {
|
||||
// empty AND filled - the clear button only shows up once theres text.
|
||||
for (final text in const ["", "12"]) {
|
||||
final h = await _height(
|
||||
tester,
|
||||
density: density,
|
||||
features: entry.value,
|
||||
text: text,
|
||||
);
|
||||
expect(
|
||||
tester.takeException(),
|
||||
isNull,
|
||||
reason: "${entry.key} threw laying out in the field",
|
||||
);
|
||||
expect(
|
||||
h,
|
||||
density.controlHeight,
|
||||
reason: "${entry.key} made the field $h",
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import "package:flutter/widgets.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:garage_ui/garage_ui.dart";
|
||||
|
||||
import "support/test_scheme.dart";
|
||||
|
||||
Widget _host(Density density, Widget child) => Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: GarageTheme(
|
||||
data: ThemeData(colorScheme: testScheme, density: density),
|
||||
child: Column(children: [child]),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
for (final density in const [Density(), Density.normal()]) {
|
||||
testWidgets("${density.control.name}: Gap.md takes its extent from the theme",
|
||||
(tester) async {
|
||||
await tester.pumpWidget(_host(density, const Gap.md()));
|
||||
expect(tester.getSize(find.byType(Gap)).height, density.gapMd);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets("a literal Gap still wins over the scale", (tester) async {
|
||||
await tester.pumpWidget(_host(const Density(), const Gap(17)));
|
||||
expect(tester.getSize(find.byType(Gap)).height, 17.0);
|
||||
});
|
||||
|
||||
_outsideFlex();
|
||||
|
||||
testWidgets("the same Gap.md moves when the density does", (tester) async {
|
||||
await tester.pumpWidget(_host(const Density(), const Gap.md()));
|
||||
final compact = tester.getSize(find.byType(Gap)).height;
|
||||
await tester.pumpWidget(_host(const Density.normal(), const Gap.md()));
|
||||
final normal = tester.getSize(find.byType(Gap)).height;
|
||||
expect(normal, greaterThan(compact));
|
||||
});
|
||||
}
|
||||
|
||||
// Regression: a Gap with no Flex parent used to throw at layout time and take
|
||||
// the enclosing subtree with it. One inside an AnimatedSize destroyed a login
|
||||
// card in the Garage hub, on a build that analyzed clean.
|
||||
void _outsideFlex() {
|
||||
testWidgets("a Gap outside a Flex lays out instead of throwing",
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: GarageTheme(
|
||||
data: ThemeData(colorScheme: testScheme, density: const Density()),
|
||||
child: const Center(child: SizedBox(width: 100, child: Gap.md())),
|
||||
),
|
||||
),
|
||||
);
|
||||
// it lays out - that is the whole point, it used to take the subtree down
|
||||
expect(tester.getSize(find.byType(Gap)).height, const Density().gapMd);
|
||||
|
||||
// and it says so loudly in debug rather than failing silently
|
||||
final reported = tester.takeException();
|
||||
expect(reported, isA<FlutterError>());
|
||||
expect("$reported", contains("no axis to size along"));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import "dart:ui" show Color;
|
||||
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:garage_ui/theme/theme_data.dart";
|
||||
|
||||
// CHARACTERISATION BASELINE — do not "fix" these numbers.
|
||||
//
|
||||
// This locks the geometry Arcs & Angles renders at today, ahead of the density
|
||||
// API being reshaped. The API is expected to change; the numbers are not. A
|
||||
// failure here means the refactor moved something on screen, which is the one
|
||||
// outcome that isn't allowed.
|
||||
//
|
||||
// Captured 28 Aug 2026 from Density() and Density.normal().
|
||||
void main() {
|
||||
group("compact", () {
|
||||
const d = Density();
|
||||
test("type", () {
|
||||
expect(d.fontSize, 10.0);
|
||||
expect(d.lineHeight, 1.1);
|
||||
expect(d.lineBox, 11.0);
|
||||
// level with the font, not the line box - an icon sized to the line box
|
||||
// reads heavier than the label beside it
|
||||
expect(d.iconSize, 10.0);
|
||||
});
|
||||
test("text ladder", () {
|
||||
expect(d.textXs, 11.0);
|
||||
expect(d.textSm, 14.0);
|
||||
expect(d.textLg, 18.0);
|
||||
});
|
||||
test("control geometry", () {
|
||||
expect(d.controlHeight, 23.0);
|
||||
expect(d.controlPaddingY, 6.0);
|
||||
expect(d.buttonPaddingX, 10.0);
|
||||
expect(d.controlGap, 4.0);
|
||||
expect(d.controlBorderWidth, 1.0);
|
||||
expect(d.buttonPadding.horizontal, 20.0);
|
||||
expect(d.buttonPadding.vertical, 12.0);
|
||||
expect(d.borderedControlPadding.horizontal, 18.0);
|
||||
expect(d.borderedControlPadding.vertical, 10.0);
|
||||
});
|
||||
test("rows", () {
|
||||
expect(d.menuRowHeight, 20.0);
|
||||
expect(d.popupRowHeight, 20.0);
|
||||
expect(d.popupMaxHeight, 240.0);
|
||||
expect(d.explorerRowHeight, 22.0);
|
||||
expect(d.explorerRowPaddingY, 5.5);
|
||||
expect(d.explorerRowBasePadding, 4.0);
|
||||
expect(d.explorerRowEndPadding, 10.0);
|
||||
expect(d.listRowIndent, 6.0);
|
||||
});
|
||||
test("containers and gaps", () {
|
||||
expect(d.containerGap, 8.0);
|
||||
expect(d.containerPadding, 16.0);
|
||||
expect([d.gapXxs, d.gapXs, d.gapSm, d.gapMd, d.gapLg, d.gapXl, d.gapXxl],
|
||||
[2.0, 4.0, 6.0, 8.0, 12.0, 16.0, 24.0]);
|
||||
});
|
||||
});
|
||||
|
||||
group("normal", () {
|
||||
const d = Density.normal();
|
||||
test("type", () {
|
||||
expect(d.fontSize, 10.0);
|
||||
expect(d.lineBox, 11.0);
|
||||
});
|
||||
test("text ladder", () {
|
||||
expect(d.textXs, 11.0);
|
||||
expect(d.textSm, 14.0);
|
||||
expect(d.textLg, 18.0);
|
||||
});
|
||||
test("control geometry", () {
|
||||
expect(d.controlHeight, 27.0);
|
||||
expect(d.controlPaddingY, 8.0);
|
||||
expect(d.buttonPaddingX, 12.0);
|
||||
expect(d.controlGap, 5.0);
|
||||
});
|
||||
test("rows", () {
|
||||
expect(d.menuRowHeight, 26.0);
|
||||
expect(d.popupRowHeight, 26.0);
|
||||
expect(d.explorerRowHeight, 30.0);
|
||||
expect(d.explorerRowBasePadding, 8.0);
|
||||
expect(d.explorerRowEndPadding, 14.0);
|
||||
expect(d.listRowIndent, 12.0);
|
||||
});
|
||||
test("containers and gaps", () {
|
||||
expect(d.containerGap, 10.0);
|
||||
expect(d.containerPadding, 20.0);
|
||||
expect([d.gapXxs, d.gapXs, d.gapSm, d.gapMd, d.gapLg, d.gapXl, d.gapXxl],
|
||||
[3.0, 5.0, 8.0, 10.0, 15.0, 20.0, 30.0]);
|
||||
});
|
||||
});
|
||||
|
||||
test("icon tiers at scaling 1.0", () {
|
||||
final t = IconThemeTokens.forDensity(const Density(), 1.0, const Color(0));
|
||||
expect(t.small.size, 10.0);
|
||||
expect(t.medium.size, 20.0);
|
||||
expect(t.large.size, 24.0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import "package:flutter/widgets.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:garage_ui/garage_ui.dart";
|
||||
|
||||
import "support/test_scheme.dart";
|
||||
|
||||
// `.iconSmall` and friends set a SIZE. They used to set the tier's colour too -
|
||||
// the global scheme foreground - which beat whatever IconTheme enclosed the
|
||||
// icon. Inside a button that meant the button's variant colour lost: a primary
|
||||
// button hands its leading icon primaryForeground, and the wrapper put
|
||||
// scheme.foreground back over it. On a dark scheme thats near-white on a
|
||||
// near-white fill, so the icon vanished.
|
||||
|
||||
Color? _resolved(WidgetTester tester, Key key) {
|
||||
final finder = find.descendant(
|
||||
of: find.byKey(key),
|
||||
matching: find.byType(Icon),
|
||||
);
|
||||
final element = finder.evaluate().first;
|
||||
final icon = element.widget as Icon;
|
||||
return icon.color ?? IconTheme.of(element).color;
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets("a size helper doesnt take the button's colour off its icon", (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: GarageTheme(
|
||||
data: ThemeData(colorScheme: testScheme, density: const Density()),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Button(
|
||||
key: const Key("bare"),
|
||||
style: const ButtonStyle.primary(),
|
||||
leading: const Icon(LucideIcons.user),
|
||||
onPressed: () {},
|
||||
child: const Text("bare"),
|
||||
),
|
||||
Button(
|
||||
key: const Key("sized"),
|
||||
style: const ButtonStyle.primary(),
|
||||
leading: const Icon(LucideIcons.user).iconSmall,
|
||||
onPressed: () {},
|
||||
child: const Text("sized"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
_resolved(tester, const Key("sized")),
|
||||
_resolved(tester, const Key("bare")),
|
||||
reason: "wrapping in .iconSmall should only change the size",
|
||||
);
|
||||
expect(
|
||||
_resolved(tester, const Key("sized")),
|
||||
testScheme.primaryForeground,
|
||||
reason: "a primary button's icon comes off primaryForeground",
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("outside a button it still falls through to the app theme", (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: GarageTheme(
|
||||
data: ThemeData(colorScheme: testScheme, density: const Density()),
|
||||
child: Center(
|
||||
child: KeyedSubtree(
|
||||
key: const Key("loose"),
|
||||
child: const Icon(LucideIcons.user).iconSmall,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(_resolved(tester, const Key("loose")), testScheme.foreground);
|
||||
});
|
||||
|
||||
testWidgets("the size still comes off the density", (tester) async {
|
||||
for (final density in const [Density(), Density.normal()]) {
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: GarageTheme(
|
||||
data: ThemeData(colorScheme: testScheme, density: density),
|
||||
child: Center(
|
||||
child: KeyedSubtree(
|
||||
key: const Key("sized"),
|
||||
child: const Icon(LucideIcons.user).iconSmall,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final element = find
|
||||
.descendant(
|
||||
of: find.byKey(const Key("sized")),
|
||||
matching: find.byType(Icon),
|
||||
)
|
||||
.evaluate()
|
||||
.first;
|
||||
expect(IconTheme.of(element).size, density.iconSize);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import "package:flutter/services.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:garage_ui/garage_ui.dart";
|
||||
|
||||
/// Type [text] one character at a time through [f], the way a keyboard would.
|
||||
///
|
||||
/// Feeding the whole string in one go would only ever test paste - and a mask
|
||||
/// that handles paste can still put the caret in the wrong place on every
|
||||
/// keystroke, which is the bug people actually hit.
|
||||
TextEditingValue _type(MaskedTextInputFormatter f, String text) {
|
||||
var v = TextEditingValue.empty;
|
||||
for (final ch in text.split("")) {
|
||||
final at = v.selection.baseOffset < 0
|
||||
? v.text.length
|
||||
: v.selection.baseOffset;
|
||||
final next = TextEditingValue(
|
||||
text: v.text.substring(0, at) + ch + v.text.substring(at),
|
||||
selection: TextSelection.collapsed(offset: at + 1),
|
||||
);
|
||||
v = f.formatEditUpdate(v, next);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/// One backspace at the caret.
|
||||
TextEditingValue _backspace(MaskedTextInputFormatter f, TextEditingValue v) {
|
||||
final at = v.selection.baseOffset;
|
||||
if (at <= 0) return v;
|
||||
return f.formatEditUpdate(
|
||||
v,
|
||||
TextEditingValue(
|
||||
text: v.text.substring(0, at - 1) + v.text.substring(at),
|
||||
selection: TextSelection.collapsed(offset: at - 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TextEditingValue _paste(MaskedTextInputFormatter f, String text) =>
|
||||
f.formatEditUpdate(
|
||||
TextEditingValue.empty,
|
||||
TextEditingValue(
|
||||
text: text,
|
||||
selection: TextSelection.collapsed(offset: text.length),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
final phone = MaskedTextInputFormatter("### ###-####");
|
||||
|
||||
group("typing", () {
|
||||
test("literals arrive on their own", () {
|
||||
expect(_type(phone, "5").text, "5");
|
||||
expect(_type(phone, "555").text, "555");
|
||||
// the space appears with the 4th digit, not before it - a trailing
|
||||
// literal you cant delete reads as the field being stuck
|
||||
expect(_type(phone, "5551").text, "555 1");
|
||||
expect(_type(phone, "555123").text, "555 123");
|
||||
expect(_type(phone, "5551234").text, "555 123-4");
|
||||
expect(_type(phone, "5551234567").text, "555 123-4567");
|
||||
});
|
||||
|
||||
test("the caret stays after what you typed", () {
|
||||
final v = _type(phone, "5551");
|
||||
expect(v.text, "555 1");
|
||||
expect(v.selection.baseOffset, 5);
|
||||
});
|
||||
|
||||
test("it stops when the mask is full", () {
|
||||
expect(_type(phone, "55512345678901").text, "555 123-4567");
|
||||
});
|
||||
|
||||
test("characters the slot wont take are dropped, not the whole edit", () {
|
||||
expect(_type(phone, "5a5b5c").text, "555");
|
||||
});
|
||||
});
|
||||
|
||||
group("deleting", () {
|
||||
test("backspace over a literal still removes a character", () {
|
||||
// the case a naive mask gets stuck on: the caret is just past the space,
|
||||
// backspace kills the space, the digits are unchanged, and re-applying
|
||||
// the mask puts the space straight back.
|
||||
var v = _type(phone, "5551");
|
||||
expect(v.text, "555 1");
|
||||
v = _backspace(phone, v);
|
||||
expect(v.text, "555");
|
||||
v = _backspace(phone, v);
|
||||
expect(v.text, "55");
|
||||
});
|
||||
|
||||
test("backspacing the lot empties the field", () {
|
||||
var v = _type(phone, "5551234567");
|
||||
for (var i = 0; i < 20; i++) {
|
||||
v = _backspace(phone, v);
|
||||
}
|
||||
expect(v.text, "");
|
||||
});
|
||||
|
||||
test("backspace at the start is a no-op, not a crash", () {
|
||||
final v = phone.formatEditUpdate(
|
||||
TextEditingValue.empty,
|
||||
TextEditingValue.empty,
|
||||
);
|
||||
expect(v.text, "");
|
||||
expect(_backspace(phone, v).text, "");
|
||||
});
|
||||
});
|
||||
|
||||
group("pasting", () {
|
||||
test("an already formatted number survives", () {
|
||||
expect(_paste(phone, "555 123-4567").text, "555 123-4567");
|
||||
});
|
||||
|
||||
test("someone elses formatting is reduced and re-laid", () {
|
||||
expect(_paste(phone, "(555) 123-4567").text, "555 123-4567");
|
||||
expect(_paste(phone, "555.123.4567").text, "555 123-4567");
|
||||
expect(_paste(phone, "5551234567").text, "555 123-4567");
|
||||
});
|
||||
|
||||
test("overlong paste is cut to the mask", () {
|
||||
expect(_paste(phone, "555123456789").text, "555 123-4567");
|
||||
});
|
||||
});
|
||||
|
||||
group("slot kinds", () {
|
||||
test("A takes letters, # takes digits", () {
|
||||
final post = MaskedTextInputFormatter("AA# #AA");
|
||||
expect(_type(post, "SW1A1AA").text, "SW1 1AA");
|
||||
// a digit cant fill a letter slot
|
||||
expect(_type(post, "12").text, "");
|
||||
});
|
||||
|
||||
test("* takes either", () {
|
||||
final any = MaskedTextInputFormatter("**-**");
|
||||
expect(_type(any, "a1b2").text, "a1-b2");
|
||||
});
|
||||
});
|
||||
|
||||
group("unmask", () {
|
||||
test("gives back only what was typed", () {
|
||||
expect(phone.unmask("555 123-4567"), "5551234567");
|
||||
expect(phone.unmask("555 1"), "5551");
|
||||
expect(phone.unmask(""), "");
|
||||
});
|
||||
|
||||
test("a literal that looks like a slot character isnt returned", () {
|
||||
// the leading 1 is part of the mask, not something anybody typed
|
||||
final trunk = MaskedTextInputFormatter("1-###-####");
|
||||
final v = _type(trunk, "5551234");
|
||||
expect(v.text, "1-555-1234");
|
||||
expect(trunk.unmask(v.text), "5551234");
|
||||
});
|
||||
|
||||
test("round trips through apply", () {
|
||||
const raw = "5551234567";
|
||||
expect(phone.unmask(phone.apply(raw)), raw);
|
||||
});
|
||||
});
|
||||
|
||||
test("slotCount counts slots, not mask length", () {
|
||||
expect(phone.slotCount, 10);
|
||||
expect(phone.mask.length, 12);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import "package:flutter/widgets.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:garage_ui/garage_ui.dart";
|
||||
|
||||
import "support/test_scheme.dart";
|
||||
|
||||
// A group inside a group. The inner one used to install its own scope over the
|
||||
// outer one's, so the outer's say was simply lost: the bottom row's field kept
|
||||
// the top corners the vertical group had flattened, and painted a top edge the
|
||||
// row above had already drawn.
|
||||
//
|
||||
// Corners are 0-or-1 keep factors, so composing is a product - a corner stays
|
||||
// round only where BOTH groups leave it alone.
|
||||
|
||||
void main() {
|
||||
ButtonGroupCorners cornersOf(WidgetTester tester, Type type) {
|
||||
final ctx = tester.element(find.byType(type));
|
||||
return ButtonGroupScope.maybeOf(ctx)!.corners;
|
||||
}
|
||||
|
||||
ButtonGroupScope scopeOf(WidgetTester tester, Key key) {
|
||||
return ButtonGroupScope.maybeOf(tester.element(find.byKey(key)))!;
|
||||
}
|
||||
|
||||
testWidgets("a horizontal group nested in a vertical one composes", (
|
||||
tester,
|
||||
) async {
|
||||
const top = Key("top");
|
||||
const bottomStart = Key("bottom-start");
|
||||
const bottomEnd = Key("bottom-end");
|
||||
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: GarageTheme(
|
||||
data: ThemeData(colorScheme: testScheme, density: const Density()),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 300,
|
||||
child: ButtonGroup.vertical(
|
||||
expands: true,
|
||||
children: [
|
||||
const SizedBox(key: top, height: 20),
|
||||
// no expands on the inner group - it keeps the
|
||||
// IntrinsicHeight that bounds it inside the vertical one.
|
||||
ButtonGroup.horizontal(
|
||||
children: const [
|
||||
Expanded(child: SizedBox(key: bottomStart, height: 20)),
|
||||
SizedBox(key: bottomEnd, height: 20, width: 20),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// top row: vertical index 0 - both top corners live, bottom flattened
|
||||
// against the row below.
|
||||
final t = scopeOf(tester, top);
|
||||
expect(
|
||||
[t.corners.topStart, t.corners.topEnd],
|
||||
[1, 1],
|
||||
reason: "the top row keeps its top corners",
|
||||
);
|
||||
expect(
|
||||
[t.corners.bottomStart, t.corners.bottomEnd],
|
||||
[0, 0],
|
||||
reason: "the top row's bottom is a seam",
|
||||
);
|
||||
expect(t.hideTop, isFalse);
|
||||
expect(t.hideStart, isFalse);
|
||||
|
||||
// bottom-left: bottom of the vertical group AND start of the horizontal
|
||||
// one, so the ONLY surviving corner is bottom-start.
|
||||
final bs = scopeOf(tester, bottomStart);
|
||||
expect(
|
||||
[
|
||||
bs.corners.topStart,
|
||||
bs.corners.topEnd,
|
||||
bs.corners.bottomStart,
|
||||
bs.corners.bottomEnd,
|
||||
],
|
||||
[0, 0, 1, 0],
|
||||
reason: "only the outer bottom-start corner survives both groups",
|
||||
);
|
||||
expect(
|
||||
bs.hideTop,
|
||||
isTrue,
|
||||
reason: "the row above already painted this edge",
|
||||
);
|
||||
expect(bs.hideStart, isFalse, reason: "its the first in its own row");
|
||||
|
||||
// bottom-right: bottom of the vertical AND end of the horizontal, so only
|
||||
// bottom-end survives - and it gives up BOTH its top and its start edge,
|
||||
// which is the pair a single axis+index could never express.
|
||||
final be = scopeOf(tester, bottomEnd);
|
||||
expect(
|
||||
[
|
||||
be.corners.topStart,
|
||||
be.corners.topEnd,
|
||||
be.corners.bottomStart,
|
||||
be.corners.bottomEnd,
|
||||
],
|
||||
[0, 0, 0, 1],
|
||||
);
|
||||
expect(be.hideTop, isTrue, reason: "the row above painted the top edge");
|
||||
expect(
|
||||
be.hideStart,
|
||||
isTrue,
|
||||
reason: "the field beside it painted the start edge",
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("an unnested group is unchanged", (tester) async {
|
||||
const a = Key("a");
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: GarageTheme(
|
||||
data: ThemeData(colorScheme: testScheme, density: const Density()),
|
||||
child: Center(
|
||||
child: ButtonGroup.horizontal(
|
||||
children: const [
|
||||
SizedBox(key: a, height: 20, width: 20),
|
||||
SizedBox(height: 20, width: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final s = scopeOf(tester, a);
|
||||
expect(
|
||||
[
|
||||
s.corners.topStart,
|
||||
s.corners.topEnd,
|
||||
s.corners.bottomStart,
|
||||
s.corners.bottomEnd,
|
||||
],
|
||||
[1, 0, 1, 0],
|
||||
);
|
||||
expect(s.hideTop, isFalse);
|
||||
expect(s.hideStart, isFalse);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import "package:flutter/widgets.dart";
|
||||
import "package:flutter_lucide/flutter_lucide.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:garage_ui/garage_ui.dart";
|
||||
|
||||
import "support/test_scheme.dart";
|
||||
|
||||
Widget _host(Widget child) => Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
// WidgetsLocalizations, which ReorderableList wants for its drag
|
||||
// semantics. A WidgetsApp brings them; this host isnt one, so it says
|
||||
// so itself rather than the widget quietly shadowing an app's locale.
|
||||
child: Localizations(
|
||||
locale: const Locale("en", "US"),
|
||||
delegates: const [DefaultWidgetsLocalizations.delegate],
|
||||
child: GarageTheme(
|
||||
data: ThemeData(colorScheme: testScheme, density: const Density()),
|
||||
// deliberately NO Material and no MaterialApp: garage's app shell is
|
||||
// a WidgetsApp, so anything material PropertiesList needs it has to
|
||||
// bring itself. The Overlay is the one thing a WidgetsApp really does
|
||||
// provide (through its Navigator), so the host provides it too.
|
||||
child: Overlay(initialEntries: [OverlayEntry(builder: (_) => child)]),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
PropertiesEntry _entry(String id, {PropertiesPin? pin, bool movable = true}) {
|
||||
return PropertiesEntry(
|
||||
id: id,
|
||||
pin: pin,
|
||||
movable: movable,
|
||||
child: Builder(
|
||||
builder: (context) => PropertiesSection(
|
||||
title: id,
|
||||
scheme: GarageTheme.of(context).colorScheme,
|
||||
collapsed: true,
|
||||
onToggle: () {},
|
||||
rows: const [],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets("every section is movable by default", (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
PropertiesList(entries: [_entry("a"), _entry("b")], onReorder: (_) {}),
|
||||
),
|
||||
);
|
||||
|
||||
// one grab handle each - the default is that things move
|
||||
expect(find.byIcon(LucideIcons.grip_horizontal), findsNWidgets(2));
|
||||
});
|
||||
|
||||
testWidgets("an unmovable section has no handle", (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
PropertiesList(
|
||||
entries: [_entry("a"), _entry("b", movable: false)],
|
||||
onReorder: (_) {},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byIcon(LucideIcons.grip_horizontal), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("a pinned section sits above the list, out of its reach", (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
PropertiesList(
|
||||
// handed over LAST, and it still comes out first
|
||||
entries: [
|
||||
_entry("a"),
|
||||
_entry("b"),
|
||||
_entry("top", pin: PropertiesPin.top),
|
||||
],
|
||||
onReorder: (_) {},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final top = tester.getTopLeft(find.text("top")).dy;
|
||||
expect(top, lessThan(tester.getTopLeft(find.text("a")).dy));
|
||||
// two movable, so two handles - the pinned one has none
|
||||
expect(find.byIcon(LucideIcons.grip_horizontal), findsNWidgets(2));
|
||||
|
||||
// and it isnt IN the reorderable list at all, which is what stops the
|
||||
// list shifting it out of the way and then bouncing the drop back.
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(ReorderableList),
|
||||
matching: find.text("top"),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(ReorderableList),
|
||||
matching: find.text("a"),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("with nothing pinned there is no stray list", (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
PropertiesList(entries: [_entry("a"), _entry("b")], onReorder: (_) {}),
|
||||
),
|
||||
);
|
||||
expect(find.byType(ReorderableList), findsOneWidget);
|
||||
});
|
||||
|
||||
test("a section is not rebuilt just because its position moved", () {
|
||||
// the whole reason the index lives in a mutable slot. Notifying on it
|
||||
// rebuilt every section in the list each time the gap shifted mid-drag -
|
||||
// rows, text fields and all - which is what made dragging feel like it
|
||||
// was catching.
|
||||
final slot = PropertiesReorderSlot(0);
|
||||
const child = SizedBox();
|
||||
|
||||
final before = PropertiesReorderScope(
|
||||
slot: slot,
|
||||
draggable: true,
|
||||
child: child,
|
||||
);
|
||||
slot.index = 3;
|
||||
final after = PropertiesReorderScope(
|
||||
slot: slot,
|
||||
draggable: true,
|
||||
child: child,
|
||||
);
|
||||
|
||||
expect(after.updateShouldNotify(before), isFalse);
|
||||
|
||||
// but whether it can be dragged at all still is worth a rebuild - thats
|
||||
// the handle appearing or going.
|
||||
final locked = PropertiesReorderScope(
|
||||
slot: slot,
|
||||
draggable: false,
|
||||
child: child,
|
||||
);
|
||||
expect(locked.updateShouldNotify(after), isTrue);
|
||||
});
|
||||
|
||||
testWidgets("a section on its own has no handle at all", (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
Builder(
|
||||
builder: (context) => PropertiesSection(
|
||||
title: "alone",
|
||||
scheme: GarageTheme.of(context).colorScheme,
|
||||
collapsed: true,
|
||||
onToggle: () {},
|
||||
rows: const [],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byIcon(LucideIcons.grip_horizontal), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import "package:flutter/widgets.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:garage_ui/garage_ui.dart";
|
||||
|
||||
import "support/test_scheme.dart";
|
||||
|
||||
// The actions band. Before this existed a Save button had to go in `rows`,
|
||||
// where it read as one more property that happened to be a button - no
|
||||
// separator, no tone, and lined up against the property split like a value.
|
||||
|
||||
Widget _host(Widget child, {Density density = const Density()}) =>
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: GarageTheme(
|
||||
data: ThemeData(colorScheme: testScheme, density: density),
|
||||
child: Center(child: SizedBox(width: 420, child: child)),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _section({List<Widget> actions = const [], bool collapsed = false}) =>
|
||||
PropertiesSection(
|
||||
title: "Profile",
|
||||
scheme: testScheme,
|
||||
collapsed: collapsed,
|
||||
onToggle: () {},
|
||||
actions: actions,
|
||||
rows: [
|
||||
PropertyRow(
|
||||
label: "Username",
|
||||
scheme: testScheme,
|
||||
child: const SizedBox(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Color? _bandColour(WidgetTester tester) {
|
||||
final containers = find
|
||||
.ancestor(
|
||||
of: find.text("Save"),
|
||||
matching: find.byType(Container),
|
||||
)
|
||||
.evaluate()
|
||||
.map((e) => e.widget as Container)
|
||||
.where((c) => c.color != null);
|
||||
return containers.isEmpty ? null : containers.first.color;
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets("no actions means no band and no separator", (tester) async {
|
||||
await tester.pumpWidget(_host(_section()));
|
||||
expect(find.byType(Divider), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets("actions get a separator and the muted band", (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
_section(
|
||||
actions: [
|
||||
Button(
|
||||
style: const ButtonStyle.primary(),
|
||||
onPressed: () {},
|
||||
child: const Text("Save"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byType(Divider), findsOneWidget);
|
||||
// toned OFF the section, not the same fill
|
||||
expect(_bandColour(tester), testScheme.muted);
|
||||
expect(_bandColour(tester),
|
||||
isNot(testScheme.card));
|
||||
});
|
||||
|
||||
testWidgets("the band spans the section, not just its buttons", (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
_section(
|
||||
actions: [
|
||||
Button(
|
||||
style: const ButtonStyle.primary(),
|
||||
onPressed: () {},
|
||||
child: const Text("Save"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const density = Density();
|
||||
final content = tester.getRect(
|
||||
find
|
||||
.ancestor(of: find.text("Save"), matching: find.byType(Row))
|
||||
.first,
|
||||
);
|
||||
final divider = tester.getRect(find.byType(Divider));
|
||||
|
||||
// the BAND spans the section edge to edge - its content is inset by the
|
||||
// same buttonPaddingX a row uses, so the buttons line up with the controls
|
||||
// above them rather than sitting on the section's edge.
|
||||
expect(content.width, divider.width - density.buttonPaddingX * 2);
|
||||
// and the whole thing sits under the rows
|
||||
expect(
|
||||
content.top,
|
||||
greaterThan(tester.getRect(find.text("Username")).bottom),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("actions collapse with the rows", (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
_section(
|
||||
collapsed: true,
|
||||
actions: [
|
||||
Button(
|
||||
style: const ButtonStyle.primary(),
|
||||
onPressed: () {},
|
||||
child: const Text("Save"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text("Save"), findsNothing);
|
||||
expect(find.byType(Divider), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import "package:flutter/widgets.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:garage_ui/garage_ui.dart";
|
||||
|
||||
import "support/test_scheme.dart";
|
||||
|
||||
// The section's own subtitle. Sits in the header under the title, and stays
|
||||
// there while the section is collapsed - it names the section rather than
|
||||
// being part of its body. Sections deliberately have no `description`; a box
|
||||
// of copy above the rows competed with them, and a row's own description
|
||||
// covers the case anyway.
|
||||
|
||||
Widget _host(Widget child) => Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: GarageTheme(
|
||||
data: ThemeData(colorScheme: testScheme, density: const Density()),
|
||||
child: Center(child: SizedBox(width: 420, child: child)),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _section({String? subtitle, bool collapsed = false}) =>
|
||||
PropertiesSection(
|
||||
title: "Profile",
|
||||
subtitle: subtitle,
|
||||
scheme: testScheme,
|
||||
collapsed: collapsed,
|
||||
onToggle: () {},
|
||||
rows: [
|
||||
PropertyRow(
|
||||
label: "Username",
|
||||
scheme: testScheme,
|
||||
child: const TextField(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets("subtitle sits under the title, above the rows", (tester) async {
|
||||
await tester.pumpWidget(_host(_section(subtitle: "a subtitle")));
|
||||
|
||||
final title = tester.getRect(find.text("Profile"));
|
||||
final sub = tester.getRect(find.text("a subtitle"));
|
||||
final row = tester.getRect(find.text("Username"));
|
||||
|
||||
expect(sub.top, greaterThanOrEqualTo(title.bottom));
|
||||
expect(sub.left, title.left); // same column, clear of the chevron
|
||||
expect(row.top, greaterThan(sub.bottom));
|
||||
});
|
||||
|
||||
testWidgets("subtitle renders at textXxs", (tester) async {
|
||||
await tester.pumpWidget(_host(_section(subtitle: "a subtitle")));
|
||||
expect(
|
||||
tester.widget<Text>(find.text("a subtitle")).style!.fontSize,
|
||||
const Density().textXxs,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("subtitle survives collapse", (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_host(_section(subtitle: "a subtitle", collapsed: true)),
|
||||
);
|
||||
|
||||
// it names the section, so it stays when the body goes
|
||||
expect(find.text("a subtitle"), findsOneWidget);
|
||||
expect(find.text("Username"), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import "package:flutter/widgets.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:garage_ui/garage_ui.dart";
|
||||
|
||||
import "support/test_scheme.dart";
|
||||
|
||||
// A property row used to be label + control and nothing else, so a page
|
||||
// needing to explain a setting had to either fold it into the label or give up
|
||||
// and hardcode a Text somewhere. Two slots now:
|
||||
//
|
||||
// subtitle - inside the label column, right aligned against the split
|
||||
// description - full width under the whole row
|
||||
//
|
||||
// Both render at density.textXxs, which is the rung below the control font -
|
||||
// it didnt exist before either, because in A&A the control font is already the
|
||||
// smallest thing on screen.
|
||||
|
||||
Widget _host(Widget child, {Density density = const Density()}) => Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: GarageTheme(
|
||||
data: ThemeData(colorScheme: testScheme, density: density),
|
||||
child: Center(child: SizedBox(width: 400, child: child)),
|
||||
),
|
||||
);
|
||||
|
||||
double _fontOf(WidgetTester tester, String text) {
|
||||
final widget = tester.widget<Text>(find.text(text));
|
||||
return widget.style!.fontSize!;
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets("subtitle and description both render at textXxs", (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
PropertyRow(
|
||||
label: "Username",
|
||||
subtitle: "Display name used across dashboard",
|
||||
description: "Changing this breaks any links to your old name.",
|
||||
scheme: testScheme,
|
||||
child: const SizedBox(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const density = Density();
|
||||
expect(density.textXxs, 9.0); // 10 * 0.875, rounded
|
||||
expect(_fontOf(tester, "Display name used across dashboard"), 9.0);
|
||||
expect(_fontOf(tester, "Changing this breaks any links to your old name."),
|
||||
9.0);
|
||||
});
|
||||
|
||||
testWidgets("both slots track the density", (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
PropertyRow(
|
||||
label: "Username",
|
||||
subtitle: "a subtitle",
|
||||
scheme: testScheme,
|
||||
child: const SizedBox(),
|
||||
),
|
||||
density: const Density(fontSize: 16.0, lineHeight: 1.25),
|
||||
),
|
||||
);
|
||||
|
||||
expect(_fontOf(tester, "a subtitle"), 14.0); // 16 * 0.875
|
||||
});
|
||||
|
||||
testWidgets("a description makes the row taller, not the control shorter", (
|
||||
tester,
|
||||
) async {
|
||||
Future<double> heightWith({String? description}) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
PropertyRow(
|
||||
key: const Key("row"),
|
||||
label: "Username",
|
||||
description: description,
|
||||
scheme: testScheme,
|
||||
child: const SizedBox(),
|
||||
),
|
||||
),
|
||||
);
|
||||
// the row eases between heights now, so the frame straight after the
|
||||
// pump is still the old one.
|
||||
await tester.pumpAndSettle();
|
||||
return tester.getSize(find.byKey(const Key("row"))).height;
|
||||
}
|
||||
|
||||
final bare = await heightWith();
|
||||
final withCopy = await heightWith(description: "some copy");
|
||||
|
||||
// the bare row is the row minimum - controlHeight plus a step, so that
|
||||
// neither the control nor the label column is the thing setting it. a
|
||||
// description adds on top of that rather than squeezing into it.
|
||||
expect(bare, const Density().propertyRowHeight);
|
||||
expect(withCopy, greaterThan(bare));
|
||||
});
|
||||
|
||||
testWidgets("the gap between section rows is gapXs, off the density", (
|
||||
tester,
|
||||
) async {
|
||||
// the numbers PropertiesSection used to hardcode - 4 between rows, 8 under
|
||||
// the last - are exactly gapXs and gapMd at compact, which is the tell they
|
||||
// were always meant to be these tokens. at normal they should now move.
|
||||
const compact = Density();
|
||||
const normal = Density.normal();
|
||||
expect(compact.gapXs, 4.0);
|
||||
expect(normal.gapXs, 5.0);
|
||||
|
||||
Future<double> gapBetweenRows(Density density) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
PropertiesSection(
|
||||
title: "Profile",
|
||||
scheme: testScheme,
|
||||
collapsed: false,
|
||||
onToggle: () {},
|
||||
rows: [
|
||||
for (var i = 0; i < 2; i++)
|
||||
PropertyRow(
|
||||
key: Key("row$i"),
|
||||
label: "Row $i",
|
||||
scheme: testScheme,
|
||||
child: const SizedBox(),
|
||||
),
|
||||
],
|
||||
),
|
||||
density: density,
|
||||
),
|
||||
);
|
||||
final first = tester.getRect(find.byKey(const Key("row0")));
|
||||
final second = tester.getRect(find.byKey(const Key("row1")));
|
||||
return second.top - first.bottom;
|
||||
}
|
||||
|
||||
expect(await gapBetweenRows(compact), compact.gapXs);
|
||||
expect(await gapBetweenRows(normal), normal.gapXs);
|
||||
});
|
||||
|
||||
testWidgets("a subtitle doesnt change the row height", (tester) async {
|
||||
// the whole reason the minimum sits above controlHeight. a label plus a
|
||||
// subtitle is laid out on the font's own metrics; at plain controlHeight
|
||||
// that made the LABEL COLUMN set the row height on some fonts and not
|
||||
// others. both rows have to come out the same.
|
||||
Future<double> h({String? subtitle}) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
PropertyRow(
|
||||
key: const Key("row"),
|
||||
label: "Username",
|
||||
subtitle: subtitle,
|
||||
scheme: testScheme,
|
||||
child: const TextField(),
|
||||
),
|
||||
),
|
||||
);
|
||||
// the row eases between heights now, so the frame straight after the
|
||||
// pump is still the old one.
|
||||
await tester.pumpAndSettle();
|
||||
return tester.getSize(find.byKey(const Key("row"))).height;
|
||||
}
|
||||
|
||||
expect(await h(subtitle: "a subtitle"), await h());
|
||||
});
|
||||
|
||||
test("the row minimum clears whichever of the two is taller", () {
|
||||
// at compact the LABEL COLUMN is the binding one - 26 against a 23px
|
||||
// control - which is the whole reason this token exists. at the roomier
|
||||
// tiers the control wins again. either way the row clears both, so a
|
||||
// subtitle never changes the height.
|
||||
const compact = Density();
|
||||
expect(compact.labelColumnHeight, 26.0);
|
||||
expect(compact.controlHeight, 23.0);
|
||||
expect(compact.propertyRowHeight, 28.0);
|
||||
|
||||
const normal = Density.normal();
|
||||
expect(normal.labelColumnHeight, 26.0);
|
||||
expect(normal.propertyRowHeight, normal.controlHeight + normal.gapXxs);
|
||||
|
||||
for (final d in [compact, normal]) {
|
||||
expect(d.propertyRowHeight, greaterThan(d.labelColumnHeight));
|
||||
expect(d.propertyRowHeight, greaterThan(d.controlHeight));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import "package:flutter/widgets.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:garage_ui/garage_ui.dart";
|
||||
|
||||
import "support/test_scheme.dart";
|
||||
|
||||
// A property section is a docked surface, so its fields are always secondary -
|
||||
// an outline field in there draws its own border and background against a
|
||||
// panel that already has both, and reads as floating. That made `variant:` a
|
||||
// parameter whose every value but one was a bug, so PropertyRow now publishes
|
||||
// PropertySlotScope and the controls resolve against it.
|
||||
//
|
||||
// Explicit does NOT win here, unlike the label scope one widget up. These
|
||||
// check exactly that.
|
||||
|
||||
// the fill a control actually painted, which is the thing the variant decides.
|
||||
Color? _fill(WidgetTester tester, Key key) {
|
||||
final box = find
|
||||
.descendant(of: find.byKey(key), matching: find.byType(DecoratedBox))
|
||||
.evaluate()
|
||||
.map((e) => e.widget as DecoratedBox)
|
||||
.map((d) => d.decoration)
|
||||
.whereType<BoxDecoration>()
|
||||
.firstWhere((d) => d.color != null);
|
||||
return box.color;
|
||||
}
|
||||
|
||||
Widget _host(Widget child) => Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: GarageTheme(
|
||||
data: ThemeData(colorScheme: testScheme, density: const Density()),
|
||||
child: Center(child: SizedBox(width: 400, child: child)),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets("a field in a property row is secondary however its asked", (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
PropertyRow(
|
||||
label: "In a row",
|
||||
scheme: testScheme,
|
||||
// asking for outline, explicitly, and losing
|
||||
child: const TextField(
|
||||
key: Key("row"),
|
||||
variant: TextFieldVariant.outline,
|
||||
),
|
||||
),
|
||||
const TextField(
|
||||
key: Key("bare"),
|
||||
variant: TextFieldVariant.outline,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(_fill(tester, const Key("row")), testScheme.secondary);
|
||||
expect(_fill(tester, const Key("bare")), isNot(testScheme.secondary));
|
||||
});
|
||||
|
||||
testWidgets("a labelless row still counts as a slot", (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
PropertyRow(
|
||||
label: "Hidden",
|
||||
scheme: testScheme,
|
||||
labelless: true,
|
||||
child: const TextField(
|
||||
key: Key("row"),
|
||||
variant: TextFieldVariant.outline,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(_fill(tester, const Key("row")), testScheme.secondary);
|
||||
});
|
||||
|
||||
testWidgets("a select in a property row is secondary too", (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
PropertyRow(
|
||||
label: "Role",
|
||||
scheme: testScheme,
|
||||
child: Select<String>(
|
||||
key: const Key("row"),
|
||||
value: "Owner",
|
||||
variant: SelectVariant.outline,
|
||||
itemBuilder: (ctx, item) => Text(item),
|
||||
onChanged: (_) {},
|
||||
popup: const SelectPopup(
|
||||
items: SelectItemList(
|
||||
children: [
|
||||
SelectItemButton<String>(value: "Owner", child: Text("Owner")),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(_fill(tester, const Key("row")), testScheme.secondary);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import "dart:math" as math;
|
||||
import "dart:ui" show Color;
|
||||
|
||||
import "package:flutter/widgets.dart" show Brightness;
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:garage_ui/theme/colour_scheme.dart";
|
||||
import "package:garage_ui/theme/schemes.dart";
|
||||
|
||||
// ColourScheme.derive exists so a new app doesn't pay a 37-colour entry fee.
|
||||
//
|
||||
// It used to be a table of HSL offsets and these tests could only say "near
|
||||
// enough". It's four measured laws now, taken off the ten hand authored
|
||||
// schemes, so most of these check the law rather than the number - and at
|
||||
// zinc's ground the numbers come out exact, which is worth pinning.
|
||||
|
||||
double _channel(double v) =>
|
||||
v <= 0.04045 ? v / 12.92 : math.pow((v + 0.055) / 1.055, 2.4).toDouble();
|
||||
|
||||
double lstar(Color c) {
|
||||
final y =
|
||||
0.2126 * _channel(c.r) + 0.7152 * _channel(c.g) + 0.0722 * _channel(c.b);
|
||||
return y > 0.008856 ? 116 * math.pow(y, 1 / 3).toDouble() - 16 : 903.3 * y;
|
||||
}
|
||||
|
||||
void main() {
|
||||
// the core of Arcs & Angles' zinc, which the laws were measured against
|
||||
ColourScheme zincish() => ColourScheme.derive(
|
||||
brightness: Brightness.dark,
|
||||
background: const Color(0xff303030),
|
||||
foreground: const Color(0xffe6e6e6),
|
||||
primary: const Color(0xff4772b3),
|
||||
);
|
||||
|
||||
// carbon's ground. 3.6 L*, so almost no room underneath - this is the one
|
||||
// that used to come apart.
|
||||
ColourScheme carbonish() => ColourScheme.derive(
|
||||
brightness: Brightness.dark,
|
||||
background: const Color(0xff0d0d0d),
|
||||
foreground: const Color(0xfff2f2f2),
|
||||
primary: const Color(0xffe8e8e8),
|
||||
);
|
||||
|
||||
test("four colours produce a complete scheme", () {
|
||||
final s = zincish();
|
||||
expect(s.chrome, isNotNull);
|
||||
expect(s.panelBorderHighlighted, isNotNull);
|
||||
expect(s.rowHovered, isNotNull);
|
||||
expect(s.controlBorder, isNotNull);
|
||||
});
|
||||
|
||||
test("reproduces zinc, the scheme it was measured from", () {
|
||||
final s = zincish();
|
||||
// not "near" - these are the hand authored values exactly. The laws were
|
||||
// read off this scheme, so anything else means a law drifted.
|
||||
expect(s.chrome, const Color(0xff161616));
|
||||
expect(s.card, const Color(0xff3d3d3d));
|
||||
expect(s.popover, const Color(0xff181818));
|
||||
expect(s.muted, const Color(0xff1d1d1d));
|
||||
expect(s.surfaceSunken, const Color(0xff282828));
|
||||
expect(s.mutedForeground, const Color(0xff999999));
|
||||
});
|
||||
|
||||
// ── law 2: recessed rungs squeeze into whatever basement there is ───────
|
||||
test("a ground with no basement squeezes rather than reflecting", () {
|
||||
final s = carbonish();
|
||||
final ground = lstar(s.background);
|
||||
|
||||
// the old up() reflected an offset that ran off the end, so a slot asked
|
||||
// to go 12 points DOWN came out 12 points UP - lighter than the surface
|
||||
// it belongs under. Every recessed slot must still be at or below ground.
|
||||
for (final (name, colour) in [
|
||||
("chrome", s.chrome),
|
||||
("popover", s.popover),
|
||||
("muted", s.muted),
|
||||
("controlFill", s.controlFill),
|
||||
("surfaceSunken", s.surfaceSunken),
|
||||
]) {
|
||||
expect(
|
||||
lstar(colour),
|
||||
lessThanOrEqualTo(ground + 0.01),
|
||||
reason: "$name must not surface above the ground it recesses from",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("squeezing keeps the order of the recessed rungs", () {
|
||||
final s = carbonish();
|
||||
// shallowest to deepest, which is the thing reflection destroyed
|
||||
expect(lstar(s.surfaceSunken), greaterThan(lstar(s.controlFill)));
|
||||
expect(lstar(s.controlFill), greaterThan(lstar(s.popover)));
|
||||
expect(lstar(s.popover), greaterThanOrEqualTo(lstar(s.chrome)));
|
||||
});
|
||||
|
||||
// ── law 3: a stroke is measured off the surface it outlines ─────────────
|
||||
test("strokes sit off their own surface, not off the ground", () {
|
||||
for (final s in [zincish(), carbonish(), GarageSchemes.light]) {
|
||||
expect(
|
||||
lstar(s.popoverBorder) - lstar(s.popover),
|
||||
closeTo(9.9, 0.6),
|
||||
reason: "popoverBorder is measured off popover",
|
||||
);
|
||||
expect(
|
||||
lstar(s.controlBorder) - lstar(s.controlFill),
|
||||
closeTo(11.8, 0.6),
|
||||
reason: "controlBorder is measured off controlFill",
|
||||
);
|
||||
expect(
|
||||
lstar(s.propertiesSectionBorder) - lstar(s.card),
|
||||
closeTo(6.2, 0.6),
|
||||
reason: "a section border outlines card, which is what fills one",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("a border always reads against the surface it outlines", () {
|
||||
// the bug this replaces: carbon's popover was pinned to black and its
|
||||
// border derived DARKER still, so the popover had no visible edge.
|
||||
for (final s in [zincish(), carbonish(), GarageSchemes.light]) {
|
||||
expect((lstar(s.popoverBorder) - lstar(s.popover)).abs(), greaterThan(3));
|
||||
expect(
|
||||
(lstar(s.controlBorder) - lstar(s.controlFill)).abs(),
|
||||
greaterThan(3),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ── law 4: text is a fraction of the span, not an absolute shift ────────
|
||||
test("muted text sits at the same fraction of the span on any ground", () {
|
||||
for (final s in [zincish(), carbonish()]) {
|
||||
final ground = lstar(s.background);
|
||||
final span = lstar(s.foreground) - ground;
|
||||
expect(
|
||||
(lstar(s.mutedForeground) - ground) / span,
|
||||
closeTo(0.607, 0.02),
|
||||
reason: "zinc and carbon both measure 60.7%, on spans of 71 and 92",
|
||||
);
|
||||
expect((lstar(s.rowText) - ground) / span, closeTo(0.814, 0.02));
|
||||
}
|
||||
});
|
||||
|
||||
test("light schemes travel the other way", () {
|
||||
final light = GarageSchemes.light;
|
||||
expect(lstar(light.chrome), lessThan(lstar(light.background)));
|
||||
expect(lstar(zincish().chrome), lessThan(lstar(zincish().background)));
|
||||
// and a light ground has basement to spare, so nothing squeezes
|
||||
expect(
|
||||
lstar(light.background) - lstar(light.chrome),
|
||||
closeTo(12.6, 0.6),
|
||||
);
|
||||
});
|
||||
|
||||
test("every derived slot has an override", () {
|
||||
final s = ColourScheme.derive(
|
||||
brightness: Brightness.dark,
|
||||
background: const Color(0xff000000),
|
||||
foreground: const Color(0xffffffff),
|
||||
primary: const Color(0xff00ff00),
|
||||
chrome: const Color(0xff123456),
|
||||
card: const Color(0xff654321),
|
||||
);
|
||||
expect(s.chrome, const Color(0xff123456));
|
||||
expect(s.card, const Color(0xff654321));
|
||||
// and the override propagates to the slots measured from it
|
||||
expect(
|
||||
lstar(s.propertiesSectionBorder) - lstar(const Color(0xff654321)),
|
||||
closeTo(6.2, 0.6),
|
||||
);
|
||||
});
|
||||
|
||||
test("GarageSchemes ship a usable pair", () {
|
||||
for (final s in [GarageSchemes.dark, GarageSchemes.light]) {
|
||||
expect(s.chrome, isNotNull);
|
||||
expect(
|
||||
(lstar(s.foreground) - lstar(s.background)).abs(),
|
||||
greaterThan(50),
|
||||
reason: "foreground must contrast with background",
|
||||
);
|
||||
}
|
||||
expect(GarageSchemes.of(Brightness.dark), same(GarageSchemes.dark));
|
||||
expect(GarageSchemes.of(Brightness.light), same(GarageSchemes.light));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:flutter/services.dart";
|
||||
import "package:flutter/widgets.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:garage_ui/garage_ui.dart";
|
||||
import "package:garage_ui/platform/cursor_lock.dart";
|
||||
|
||||
import "support/test_scheme.dart";
|
||||
|
||||
// Drag-to-scrub used to be macOS only without anybody saying so. The moment
|
||||
// the drag threshold was crossed the field handed everything over to
|
||||
// CursorLock and ignored Flutters own pointer stream forever after - and
|
||||
// CursorLock has no native handler off macOS, so on web (and windows/linux)
|
||||
// the deltas it was waiting on never turned up. The field just froze on the
|
||||
// value it started on.
|
||||
|
||||
Future<TextEditingController> pumpField(
|
||||
WidgetTester tester, {
|
||||
String text = "10.0",
|
||||
}) async {
|
||||
final controller = TextEditingController(text: text);
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: GarageTheme(
|
||||
data: ThemeData(colorScheme: testScheme),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 200,
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
features: const [InputFeature.scrub()],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
return controller;
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets("no native cursor lock - the pointer stream drives the scrub", (
|
||||
tester,
|
||||
) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
|
||||
expect(CursorLock.isSupported, isFalse);
|
||||
|
||||
final controller = await pumpField(tester);
|
||||
|
||||
// real pointer moves arrive a few px at a time, so drive it that way
|
||||
// rather than as one giant jump. default sensitivity is 0.1/px, and the
|
||||
// first move only arms the drag (it's the one the click threshold eats),
|
||||
// so 100 counted px = +10.
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byType(TextField)),
|
||||
);
|
||||
for (var i = 0; i < 21; i++) {
|
||||
await gesture.moveBy(const Offset(5, 0));
|
||||
await tester.pump();
|
||||
}
|
||||
await gesture.up();
|
||||
await tester.pump();
|
||||
|
||||
expect(double.parse(controller.text), closeTo(20.0, 0.5));
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
testWidgets("with a native cursor lock the pointer stream is ignored", (
|
||||
tester,
|
||||
) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.macOS;
|
||||
const channel = MethodChannel("garage/cursor_lock");
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (_) async => null);
|
||||
|
||||
final controller = await pumpField(tester);
|
||||
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byType(TextField)),
|
||||
);
|
||||
for (var i = 0; i < 21; i++) {
|
||||
await gesture.moveBy(const Offset(5, 0));
|
||||
await tester.pump();
|
||||
}
|
||||
await gesture.up();
|
||||
await tester.pump();
|
||||
|
||||
// the native side would have sent deltas back over the channel; nothing
|
||||
// did here, so the value must not have budged.
|
||||
expect(double.parse(controller.text), closeTo(10.0, 1e-9));
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import "package:flutter/widgets.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:garage_ui/garage_ui.dart";
|
||||
|
||||
import "support/test_scheme.dart";
|
||||
|
||||
// the rows sat flush against each other and the surface took the same radius
|
||||
// they do, so the corner gap didnt match the gap down the sides.
|
||||
|
||||
const _density = Density();
|
||||
|
||||
Widget _host() => Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: GarageTheme(
|
||||
data: ThemeData(colorScheme: testScheme, density: _density),
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: SizedBox(
|
||||
width: 600,
|
||||
height: 400,
|
||||
child: Overlay(
|
||||
initialEntries: [
|
||||
OverlayEntry(
|
||||
builder: (_) => Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: SizedBox(
|
||||
width: 160,
|
||||
child: Select<String>(
|
||||
value: "normal",
|
||||
itemBuilder: (ctx, v) => const Text("Normal"),
|
||||
onChanged: (_) {},
|
||||
popup: const SelectPopup(
|
||||
items: SelectItemList(
|
||||
children: [
|
||||
SelectItemButton<String>(
|
||||
value: "normal",
|
||||
child: Text("Normal"),
|
||||
),
|
||||
SelectItemButton<String>(
|
||||
value: "comfortable",
|
||||
child: Text("Comfortable"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Rect _rowOf(WidgetTester tester, String label) => tester.getRect(
|
||||
find.ancestor(
|
||||
of: find.text(label),
|
||||
matching: find.byType(SelectItemButton<String>),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets("theres air between one row and the next", (tester) async {
|
||||
await tester.pumpWidget(_host());
|
||||
await tester.tap(find.byType(Select<String>));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final first = _rowOf(tester, "Normal");
|
||||
final second = _rowOf(tester, "Comfortable");
|
||||
|
||||
final gap = second.top - first.bottom;
|
||||
expect(gap, greaterThan(0), reason: "rows were flush");
|
||||
expect(gap, closeTo(_density.gapXxs, 0.5));
|
||||
});
|
||||
|
||||
testWidgets("the surface rounds around the rows, less a couple of px", (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_host());
|
||||
await tester.tap(find.byType(Select<String>));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final theme = ThemeData(colorScheme: testScheme, density: _density);
|
||||
final inset = theme.density.containerGap * 0.5;
|
||||
final border = theme.density.controlBorderWidth;
|
||||
|
||||
// the surface is the one decorated box wrapping the rows
|
||||
final surface = tester.widgetList<Container>(find.byType(Container)).where((
|
||||
c,
|
||||
) {
|
||||
final d = c.decoration;
|
||||
return d is BoxDecoration && d.borderRadius != null && d.border != null;
|
||||
});
|
||||
expect(surface, isNotEmpty);
|
||||
|
||||
final radii = surface
|
||||
.map((c) => ((c.decoration as BoxDecoration).borderRadius
|
||||
as BorderRadius).topLeft.x)
|
||||
.toList();
|
||||
final expected = theme.radiusMd + inset + border - 2;
|
||||
expect(
|
||||
radii.any((r) => (r - expected).abs() < 0.01),
|
||||
isTrue,
|
||||
reason: "wanted a surface at $expected, saw $radii",
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("it hangs off the trigger by gapSm, not the container gap", (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_host());
|
||||
final trigger = tester.getRect(find.byType(Select<String>));
|
||||
await tester.tap(find.byType(Select<String>));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final scroller = tester.getRect(find.byType(SingleChildScrollView).first);
|
||||
// the scroller starts just inside the surface's border, so back that off
|
||||
// to get the surface's own top edge
|
||||
final border = _density.controlBorderWidth;
|
||||
final toSurface = scroller.top - border - trigger.bottom;
|
||||
expect(toSurface, closeTo(_density.gapSm, 0.5));
|
||||
expect(toSurface, lessThan(_density.containerGap));
|
||||
});
|
||||
|
||||
testWidgets("the rows still sit inside the padding", (tester) async {
|
||||
await tester.pumpWidget(_host());
|
||||
await tester.tap(find.byType(Select<String>));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final row = _rowOf(tester, "Normal");
|
||||
final theme = ThemeData(colorScheme: testScheme, density: _density);
|
||||
final inset = theme.density.containerGap * 0.5;
|
||||
|
||||
// the scroller holds the padding, so the row starts one inset inside it
|
||||
final scroller = tester.getRect(find.byType(SingleChildScrollView).first);
|
||||
expect(row.left - scroller.left, closeTo(inset, 0.5));
|
||||
expect(row.top - scroller.top, closeTo(inset, 0.5));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import "package:flutter/widgets.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:garage_ui/garage_ui.dart";
|
||||
|
||||
import "support/test_scheme.dart";
|
||||
|
||||
Widget _select() => Select<String>(
|
||||
value: "off",
|
||||
itemBuilder: (ctx, v) => const Text("Off"),
|
||||
onChanged: (_) {},
|
||||
popup: SelectPopup(
|
||||
items: SelectItemList(
|
||||
children: const [
|
||||
SelectItemButton<String>(value: "off", child: Text("Off")),
|
||||
SelectItemButton<String>(
|
||||
value: "optional",
|
||||
child: Text("Optional"),
|
||||
),
|
||||
SelectItemButton<String>(
|
||||
value: "required",
|
||||
child: Text("Required"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _host({required Alignment where, double overlayWidth = 600}) =>
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: GarageTheme(
|
||||
data: ThemeData(colorScheme: testScheme, density: const Density()),
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: SizedBox(
|
||||
width: overlayWidth,
|
||||
height: 400,
|
||||
child: Overlay(
|
||||
initialEntries: [
|
||||
OverlayEntry(
|
||||
builder: (_) => Align(
|
||||
alignment: where,
|
||||
// narrow, the way a ghost select showing "Off" is narrow
|
||||
child: SizedBox(width: 70, child: _select()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets("it sizes to its widest row, not to the room available", (
|
||||
tester,
|
||||
) async {
|
||||
// given a ceiling, a ListView based popup filled the ceiling - a 70px
|
||||
// trigger opening a popup most of 600px wide.
|
||||
await tester.pumpWidget(_host(where: Alignment.topLeft));
|
||||
await tester.tap(find.byType(Select<String>));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final row = tester.getRect(
|
||||
find.ancestor(
|
||||
of: find.text("Required"),
|
||||
matching: find.byType(SelectItemButton<String>),
|
||||
),
|
||||
);
|
||||
|
||||
// wide enough for the longest option and its tick, nowhere near the 600
|
||||
// it was offered
|
||||
expect(row.width, greaterThan(70));
|
||||
expect(row.width, lessThan(300));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets("a narrow trigger near an edge still gets a readable popup", (
|
||||
tester,
|
||||
) async {
|
||||
// the sliver: clamped to the room on the side the placement picked by
|
||||
// testing the FLOOR, the popup came out one character wide.
|
||||
await tester.pumpWidget(_host(where: Alignment.topRight));
|
||||
await tester.tap(find.byType(Select<String>));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
for (final label in ["Off", "Optional", "Required"]) {
|
||||
final row = tester.getRect(
|
||||
find.ancestor(
|
||||
of: find.text(label),
|
||||
matching: find.byType(SelectItemButton<String>),
|
||||
),
|
||||
);
|
||||
// one line each, not a column of letters
|
||||
expect(row.height, lessThan(40), reason: label);
|
||||
// and never narrower than the trigger it came out of
|
||||
expect(row.width, greaterThanOrEqualTo(70.0), reason: label);
|
||||
}
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets("the popup grows past a narrow trigger to fit its rows", (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_host(where: Alignment.topLeft));
|
||||
await tester.tap(find.byType(Select<String>));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// the row that wrapped onto two lines when the popup was pinned to a
|
||||
// 70px trigger
|
||||
final optional = tester.getSize(find.text("Optional"));
|
||||
expect(optional.height, lessThan(30));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets("and it stays inside the overlay when the trigger is at the "
|
||||
"right edge", (tester) async {
|
||||
// the case that broke: a select hard against the right of a settings row.
|
||||
// Left aligned to the trigger, a wider popup runs off the edge and gets
|
||||
// clipped by whatever bounds it - a square corner and rows cut mid-word.
|
||||
await tester.pumpWidget(_host(where: Alignment.topRight));
|
||||
await tester.tap(find.byType(Select<String>));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
for (final label in ["Off", "Optional", "Required"]) {
|
||||
final row = tester.getRect(
|
||||
find.ancestor(
|
||||
of: find.text(label),
|
||||
matching: find.byType(SelectItemButton<String>),
|
||||
),
|
||||
);
|
||||
expect(row.left, greaterThanOrEqualTo(0.0), reason: label);
|
||||
expect(row.right, lessThanOrEqualTo(600.0), reason: label);
|
||||
}
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import "package:flutter/widgets.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:garage_ui/garage_ui.dart";
|
||||
|
||||
import "support/test_scheme.dart";
|
||||
|
||||
Widget _host(Widget child) => Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: GarageTheme(
|
||||
data: ThemeData(colorScheme: testScheme, density: const Density()),
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: SizedBox(width: 400, child: child),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets("rules go between entries, not around them", (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
SettingsList(
|
||||
children: [
|
||||
SettingsRow(label: "Full name", field: const Text("Benjamin")),
|
||||
SettingsRow(label: "Nickname", field: const Text("Benji")),
|
||||
SettingsRow(label: "Work", field: const Text("Engineering")),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// three rows, two rules - a rule above the first or below the last would
|
||||
// draw the box this exists to not be
|
||||
expect(find.byType(Divider), findsNWidgets(2));
|
||||
});
|
||||
|
||||
testWidgets("the rule runs wider than the rows it separates", (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
SettingsList(
|
||||
children: [
|
||||
SettingsRow(label: "Read", field: const Text("Optional")),
|
||||
SettingsRow(label: "Change", field: const Text("Optional")),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final rule = tester.getRect(find.byType(Divider));
|
||||
final row = tester.getRect(find.byType(SettingsRow).first);
|
||||
|
||||
// overshoots at both ends - a rule that stops where its content stops
|
||||
// reads as the edge of a box rather than a divider
|
||||
expect(rule.left, lessThan(row.left));
|
||||
expect(rule.right, greaterThan(row.right));
|
||||
});
|
||||
|
||||
testWidgets("one entry has no rule at all", (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
SettingsList(
|
||||
children: [
|
||||
SettingsRow(label: "Full name", field: const Text("Benjamin")),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.byType(Divider), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets("label takes the room the field doesnt", (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
SettingsList(
|
||||
children: [
|
||||
SettingsRow(
|
||||
label: "What best describes your work?",
|
||||
field: const SizedBox(width: 120, height: 20),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final label = tester.getRect(find.text("What best describes your work?"));
|
||||
final field = tester.getRect(find.byType(SizedBox).last);
|
||||
final row = tester.getRect(find.byType(SettingsRow));
|
||||
|
||||
// label on the left, field hard against the right edge - not floating in
|
||||
// the middle at the left of its own share of the row
|
||||
expect(label.left, lessThan(field.left));
|
||||
expect(field.width, 120);
|
||||
expect(field.right, closeTo(row.right, 1));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets("a field with flex children inside it doesnt assert", (
|
||||
tester,
|
||||
) async {
|
||||
// the field slot is bounded, not unbounded - a ButtonGroup with `fill`,
|
||||
// or anything else holding an Expanded, needs a width to divide up.
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
SettingsList(
|
||||
children: [
|
||||
SettingsRow(
|
||||
label: "Read",
|
||||
field: ButtonGroup.horizontal(
|
||||
fill: true,
|
||||
children: [
|
||||
Button(
|
||||
style: const ButtonStyle.outline(),
|
||||
onPressed: () {},
|
||||
child: const Text("Off"),
|
||||
),
|
||||
Button(
|
||||
style: const ButtonStyle.outline(),
|
||||
onPressed: () {},
|
||||
child: const Text("Required"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets("a long label doesnt push the field off the row", (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_host(
|
||||
SettingsList(
|
||||
children: [
|
||||
SettingsRow(
|
||||
label: "See your account, passkeys and sign-in activity, and "
|
||||
"everything else besides",
|
||||
field: const SizedBox(width: 120, height: 20),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(tester.getSize(find.byType(SizedBox).last).width, 120);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import "dart:ui" show Color;
|
||||
import "package:flutter/widgets.dart" show Brightness;
|
||||
import "package:garage_ui/theme/colour_scheme.dart";
|
||||
|
||||
/// A throwaway scheme for widget tests, built through the deriving factory so
|
||||
/// tests don't each carry a 54-colour literal.
|
||||
final testScheme = ColourScheme.derive(
|
||||
brightness: Brightness.dark,
|
||||
background: const Color(0xff303030),
|
||||
foreground: const Color(0xffe6e6e6),
|
||||
primary: const Color(0xff4772b3),
|
||||
);
|
||||
@@ -0,0 +1,69 @@
|
||||
import "package:flutter/widgets.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:garage_ui/garage_ui.dart";
|
||||
|
||||
import "support/test_scheme.dart";
|
||||
|
||||
// Every control's label is control type: density.fontSize at typography.medium.
|
||||
// They sit next to each other in a popup or a form, so a menu row rendering a
|
||||
// pixel larger and a weight lighter than the button beside it is a bug you can
|
||||
// see. This pins them together.
|
||||
void main() {
|
||||
final theme = ThemeData(colorScheme: testScheme, density: const Density());
|
||||
|
||||
Future<TextStyle> resolve(WidgetTester t, Widget w) async {
|
||||
await t.pumpWidget(Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: GarageTheme(
|
||||
data: theme,
|
||||
child: Center(child: SizedBox(width: 320, child: w)),
|
||||
),
|
||||
));
|
||||
await t.pump();
|
||||
final f = find.text("Label").first;
|
||||
final widget = t.widget<Text>(f);
|
||||
final inherited = DefaultTextStyle.of(t.element(f)).style;
|
||||
return widget.style == null ? inherited : inherited.merge(widget.style);
|
||||
}
|
||||
|
||||
final controls = <String, Widget>{
|
||||
"Button": Button(
|
||||
style: const ButtonStyle.ghost(),
|
||||
onPressed: () {},
|
||||
child: const Text("Label")),
|
||||
"TextField": const TextField(placeholder: Text("Label")),
|
||||
"Select": Select<String>(
|
||||
value: "Label",
|
||||
itemBuilder: (c, v) => Text(v),
|
||||
popup: (c) => const SizedBox.shrink(),
|
||||
onChanged: (_) {}),
|
||||
"MenuButton": MenuPopup(
|
||||
children: [MenuButton(onPressed: (_) {}, child: const Text("Label"))]),
|
||||
};
|
||||
|
||||
for (final e in controls.entries) {
|
||||
testWidgets("${e.key} label is control type", (t) async {
|
||||
final s = await resolve(t, e.value);
|
||||
expect(s.fontSize, theme.density.fontSize,
|
||||
reason: "${e.key} should render at the control font");
|
||||
expect(s.fontWeight, theme.typography.medium.fontWeight,
|
||||
reason: "${e.key} should render at the control weight");
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets("a menu row matches the button beside it exactly", (t) async {
|
||||
final button = await resolve(t, controls["Button"]!);
|
||||
final menu = await resolve(t, controls["MenuButton"]!);
|
||||
expect(menu.fontSize, button.fontSize);
|
||||
expect(menu.fontWeight, button.fontWeight);
|
||||
expect(menu.color, button.color,
|
||||
reason: "menu items read foreground, same slot as every other control");
|
||||
});
|
||||
|
||||
testWidgets("a section label is heavier but the same size", (t) async {
|
||||
final label = await resolve(
|
||||
t, MenuPopup(children: const [MenuLabel(child: Text("Label"))]));
|
||||
expect(label.fontSize, theme.density.fontSize);
|
||||
expect(label.fontWeight, theme.typography.semiBold.fontWeight);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import "package:flutter/semantics.dart";
|
||||
import "package:flutter/widgets.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:garage_ui/garage_ui.dart";
|
||||
|
||||
import "support/test_scheme.dart";
|
||||
|
||||
// with the semantics tree on, flutter web focuses a field by asking through
|
||||
// semantics rather than by a tap on the field. It used to go unanswered.
|
||||
|
||||
Widget _host(Widget child) => Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: MediaQuery(
|
||||
data: const MediaQueryData(),
|
||||
child: GarageTheme(
|
||||
data: ThemeData(colorScheme: testScheme),
|
||||
child: Overlay(
|
||||
initialEntries: [
|
||||
OverlayEntry(
|
||||
builder: (_) => Center(child: SizedBox(width: 300, child: child)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
for (final action in [SemanticsAction.tap, SemanticsAction.focus]) {
|
||||
testWidgets("$action from semantics focuses the field", (tester) async {
|
||||
final handle = tester.ensureSemantics();
|
||||
final focus = FocusNode();
|
||||
addTearDown(focus.dispose);
|
||||
|
||||
await tester.pumpWidget(_host(TextField(focusNode: focus)));
|
||||
expect(focus.hasFocus, isFalse);
|
||||
|
||||
final node = tester.getSemantics(find.byType(EditableText));
|
||||
node.owner!.performAction(node.id, action);
|
||||
await tester.pump();
|
||||
|
||||
expect(focus.hasFocus, isTrue);
|
||||
handle.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets("a disabled field doesnt offer to be focused", (tester) async {
|
||||
final handle = tester.ensureSemantics();
|
||||
await tester.pumpWidget(_host(const TextField(enabled: false)));
|
||||
final node = tester.getSemantics(find.byType(EditableText));
|
||||
final data = node.getSemanticsData();
|
||||
expect(data.hasAction(SemanticsAction.tap), isFalse);
|
||||
expect(data.hasAction(SemanticsAction.focus), isFalse);
|
||||
handle.dispose();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:garage_ui/theme/theme_data.dart";
|
||||
|
||||
// The whole point of the ladder: text that isn't in a control has to stay in
|
||||
// step with the control font. If someone retunes fontSize and textXs stops
|
||||
// tracking it, a dialog title drifts away from the button under it again.
|
||||
void main() {
|
||||
for (final d in const [Density(), Density.normal()]) {
|
||||
final name = d.control.name;
|
||||
|
||||
test("$name: textXs sits a step above the control font", () {
|
||||
expect(d.textXs, greaterThan(d.fontSize),
|
||||
reason: "dialog copy is meant to read larger than a button label");
|
||||
});
|
||||
|
||||
test("$name: the ladder climbs and stays on whole pixels", () {
|
||||
final steps = [d.textXs, d.textSm, d.textLg];
|
||||
for (var i = 1; i < steps.length; i++) {
|
||||
expect(steps[i], greaterThan(steps[i - 1]));
|
||||
}
|
||||
for (final s in steps) {
|
||||
expect(s, s.roundToDouble(), reason: "$s is not a whole pixel");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("the two larger tiers keep the values they replaced", () {
|
||||
// these were hardcoded 12/14/18 - a retune of fontSize is meant to move
|
||||
// them, but at the shipped densities they must not shift at all.
|
||||
for (final d in const [Density(), Density.normal()]) {
|
||||
expect(d.textXs, 11.0);
|
||||
expect(d.textSm, 14.0);
|
||||
expect(d.textLg, 18.0);
|
||||
}
|
||||
});
|
||||
|
||||
test("a retuned density carries the whole ladder with it", () {
|
||||
const big = Density(fontSize: 20);
|
||||
expect(big.textXs, 22.0);
|
||||
expect(big.textSm, 28.0);
|
||||
expect(big.textLg, 36.0);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user