Stack backend config dialog actions on narrow screens

The Test/Cancel/Save row was scaled down uniformly by a FittedBox on
narrow phones, making the buttons tiny. Lay them out at full size in a
Row on wide screens and stack them vertically (Test, Save, Cancel) on
narrow ones.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-06-22 11:56:10 -04:00
co-authored by Claude Opus 4.8
parent a052c67eb6
commit ecc9a34504
2 changed files with 119 additions and 56 deletions
@@ -63,6 +63,9 @@ class _BackendConfigDialogState extends State<_BackendConfigDialog> {
// measured at this width so the FittedBox can scale the whole row down on // measured at this width so the FittedBox can scale the whole row down on
// narrower screens. // narrower screens.
static const _dialogContentWidth = 420.0; static const _dialogContentWidth = 420.0;
// Below this screen width the dialog is about as wide as the screen and the
// Test / Cancel / Save row stops fitting, so the action buttons stack.
static const _stackActionsBelowWidth = 480.0;
@override @override
void initState() { void initState() {
@@ -144,9 +147,81 @@ class _BackendConfigDialogState extends State<_BackendConfigDialog> {
} }
} }
Widget _testButton() {
final appColors = Theme.of(context).extension<AppColorExtension>()!;
final colorScheme = Theme.of(context).colorScheme;
return FilledButton.tonalIcon(
onPressed: _testConnection,
label: const Text('Test'),
icon: Icon(
_testOk
? Icons.check
: _testFailed
? Icons.error_outline
: Icons.play_arrow,
),
style: _testOk
? FilledButton.styleFrom(
backgroundColor: appColors.success,
foregroundColor: appColors.onSuccess,
)
: _testFailed
? FilledButton.styleFrom(
backgroundColor: colorScheme.error,
foregroundColor: colorScheme.onError,
)
: null,
);
}
Widget _cancelButton() => TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
);
Widget _saveButton({required bool saveDisabled}) => FilledButton.icon(
onPressed: saveDisabled ? null : _save,
label: const Text('Save'),
icon: const Icon(Icons.save),
);
// Wide layout: Test on the leading edge, Cancel + Save grouped trailing.
Widget _buildRowActions() {
final saveDisabled = _connectionModified && !_testOk;
return Row(
children: [
_testButton(),
const Spacer(),
if (widget.dismissible) ...[
_cancelButton(),
const SizedBox(width: Insets.sm),
],
_saveButton(saveDisabled: saveDisabled),
],
);
}
// Narrow layout: full-width buttons stacked vertically (Test, then Save, then
// Cancel) so they stay full-size and comfortably tappable. Test leads since
// it gates Save, and Cancel sits last as the dismissing action.
Widget _buildStackedActions() {
final saveDisabled = _connectionModified && !_testOk;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_testButton(),
const SizedBox(height: Insets.sm),
_saveButton(saveDisabled: saveDisabled),
if (widget.dismissible) ...[
const SizedBox(height: Insets.sm),
_cancelButton(),
],
],
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final appColors = Theme.of(context).extension<AppColorExtension>()!;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final saveDisabled = _connectionModified && !_testOk; final saveDisabled = _connectionModified && !_testOk;
@@ -249,62 +324,25 @@ class _BackendConfigDialogState extends State<_BackendConfigDialog> {
// single high-emphasis (filled) action in the dialog; once the test // single high-emphasis (filled) action in the dialog; once the test
// succeeds it recolours to the success accent. // succeeds it recolours to the success accent.
// //
// Handing the dialog's default OverflowBar three icon buttons makes it // When the three buttons fit at full size we lay them out in a single
// stack them onto two lines on narrow phones once their combined width // Row with the Test action spread to the leading edge. On a narrow
// exceeds the dialog width. Instead we lay them out in a single Row at // phone the row would otherwise overflow, so instead of shrinking the
// the dialog's content width and wrap it in a FittedBox(scaleDown): on // buttons we stack them vertically at full size (confirming action on
// wider screens it renders at full size with the spread intact, and on // top, per Material 3), which keeps them comfortably tappable.
// a too-narrow phone the whole row scales down uniformly to fit rather actionsPadding: const EdgeInsets.fromLTRB(
// than wrapping or overflowing. Insets.lg,
0,
Insets.lg,
Insets.lg,
),
actions: [ actions: [
SizedBox( // Below this width the dialog shrinks to roughly the screen width and
width: double.infinity, // the three-button row no longer fits, so stack the buttons instead
child: FittedBox( // of squeezing them. (AlertDialog measures its actions' intrinsics, so
fit: BoxFit.scaleDown, // a LayoutBuilder can't be used here.)
child: SizedBox( MediaQuery.sizeOf(context).width < _stackActionsBelowWidth
width: _dialogContentWidth, ? _buildStackedActions()
child: Row( : _buildRowActions(),
children: [
FilledButton.tonalIcon(
onPressed: _testConnection,
label: const Text('Test'),
icon: Icon(
_testOk
? Icons.check
: _testFailed
? Icons.error_outline
: Icons.play_arrow,
),
style: _testOk
? FilledButton.styleFrom(
backgroundColor: appColors.success,
foregroundColor: appColors.onSuccess,
)
: _testFailed
? FilledButton.styleFrom(
backgroundColor: colorScheme.error,
foregroundColor: colorScheme.onError,
)
: null,
),
const Spacer(),
if (widget.dismissible) ...[
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
const SizedBox(width: Insets.sm),
],
FilledButton.icon(
onPressed: saveDisabled ? null : _save,
label: const Text('Save'),
icon: const Icon(Icons.save),
),
],
),
),
),
),
], ],
), ),
); );
@@ -159,6 +159,31 @@ void main() {
expect(PrefUtil.getValue('base_url', ''), 'http://my.server/api'); expect(PrefUtil.getValue('base_url', ''), 'http://my.server/api');
}); });
testWidgets('stacks full-size action buttons on a narrow screen', (
tester,
) async {
// Pump on a narrow phone surface so the three-button action row can't fit.
await pumpScreen(tester, const Settings(), size: const Size(360, 800));
await tester.pump(const Duration(milliseconds: 10));
await tester.tap(find.widgetWithText(TextButton, 'Re-configure'));
await tester.pumpAndSettle();
final testButton = find.widgetWithText(FilledButton, 'Test');
final saveButton = find.widgetWithText(FilledButton, 'Save');
// Test on top, Save below it: the buttons are stacked, not squeezed into a
// single shrunken row.
expect(
tester.getTopLeft(testButton).dy,
lessThan(tester.getTopLeft(saveButton).dy),
);
// Each button is laid out at a comfortable, full-size width rather than
// being scaled down to a tiny sliver.
expect(tester.getSize(saveButton).width, greaterThan(200));
expect(tester.getSize(testButton).width, greaterThan(200));
});
testWidgets('first run auto-opens a non-dismissible dialog', (tester) async { testWidgets('first run auto-opens a non-dismissible dialog', (tester) async {
await PrefUtil.setValue('base_url', ''); await PrefUtil.setValue('base_url', '');
await pumpScreen(tester, const Settings()); await pumpScreen(tester, const Settings());