mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
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:
co-authored by
Claude Opus 4.8
parent
a052c67eb6
commit
ecc9a34504
@@ -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());
|
||||||
|
|||||||
Reference in New Issue
Block a user