From ecc9a3450401b0ebe5f676acacc0f7a7f26c73dd Mon Sep 17 00:00:00 2001 From: rzuasti Date: Mon, 22 Jun 2026 11:56:10 -0400 Subject: [PATCH] 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 --- .../lib/settings/backend_config_dialog.dart | 150 +++++++++++------- .../widget/settings_config_dialog_test.dart | 25 +++ 2 files changed, 119 insertions(+), 56 deletions(-) diff --git a/frontend/lib/settings/backend_config_dialog.dart b/frontend/lib/settings/backend_config_dialog.dart index 1038b79..b95d07b 100644 --- a/frontend/lib/settings/backend_config_dialog.dart +++ b/frontend/lib/settings/backend_config_dialog.dart @@ -63,6 +63,9 @@ class _BackendConfigDialogState extends State<_BackendConfigDialog> { // measured at this width so the FittedBox can scale the whole row down on // narrower screens. 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 void initState() { @@ -144,9 +147,81 @@ class _BackendConfigDialogState extends State<_BackendConfigDialog> { } } + Widget _testButton() { + final appColors = Theme.of(context).extension()!; + 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 Widget build(BuildContext context) { - final appColors = Theme.of(context).extension()!; final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; final saveDisabled = _connectionModified && !_testOk; @@ -249,62 +324,25 @@ class _BackendConfigDialogState extends State<_BackendConfigDialog> { // single high-emphasis (filled) action in the dialog; once the test // succeeds it recolours to the success accent. // - // Handing the dialog's default OverflowBar three icon buttons makes it - // stack them onto two lines on narrow phones once their combined width - // exceeds the dialog width. Instead we lay them out in a single Row at - // the dialog's content width and wrap it in a FittedBox(scaleDown): on - // wider screens it renders at full size with the spread intact, and on - // a too-narrow phone the whole row scales down uniformly to fit rather - // than wrapping or overflowing. + // When the three buttons fit at full size we lay them out in a single + // Row with the Test action spread to the leading edge. On a narrow + // phone the row would otherwise overflow, so instead of shrinking the + // buttons we stack them vertically at full size (confirming action on + // top, per Material 3), which keeps them comfortably tappable. + actionsPadding: const EdgeInsets.fromLTRB( + Insets.lg, + 0, + Insets.lg, + Insets.lg, + ), actions: [ - SizedBox( - width: double.infinity, - child: FittedBox( - fit: BoxFit.scaleDown, - child: SizedBox( - width: _dialogContentWidth, - child: Row( - 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), - ), - ], - ), - ), - ), - ), + // Below this width the dialog shrinks to roughly the screen width and + // the three-button row no longer fits, so stack the buttons instead + // of squeezing them. (AlertDialog measures its actions' intrinsics, so + // a LayoutBuilder can't be used here.) + MediaQuery.sizeOf(context).width < _stackActionsBelowWidth + ? _buildStackedActions() + : _buildRowActions(), ], ), ); diff --git a/frontend/test/widget/settings_config_dialog_test.dart b/frontend/test/widget/settings_config_dialog_test.dart index 671faea..c957e13 100644 --- a/frontend/test/widget/settings_config_dialog_test.dart +++ b/frontend/test/widget/settings_config_dialog_test.dart @@ -159,6 +159,31 @@ void main() { 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 { await PrefUtil.setValue('base_url', ''); await pumpScreen(tester, const Settings());