From d5c575cbfec842cb8ac658fc01a1550fcd4e5aca Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:19:38 +0200 Subject: [PATCH] fix: keep download toggle label on one line --- lib/utils/dialogs.dart | 47 ++++++++++--- lib/widgets/focusable_list_tile.dart | 18 +++++ test/widgets/option_picker_dialog_test.dart | 73 +++++++++++++++++++++ 3 files changed, 128 insertions(+), 10 deletions(-) create mode 100644 test/widgets/option_picker_dialog_test.dart diff --git a/lib/utils/dialogs.dart b/lib/utils/dialogs.dart index cbf25d2a..313ab115 100644 --- a/lib/utils/dialogs.dart +++ b/lib/utils/dialogs.dart @@ -388,22 +388,47 @@ class _OptionPickerDialogState extends State<_OptionPickerDialog> { @override Widget build(BuildContext context) { - const rowPadding = EdgeInsets.symmetric(horizontal: 24, vertical: 4); + const rowPadding = EdgeInsets.symmetric(horizontal: 12, vertical: 4); + const rowHorizontalTitleGap = 8.0; + const rowMinLeadingWidth = 24.0; final toggle = widget.toggle; + void updateToggle(bool value) { + setState(() => _toggleValue = value); + toggle?.onChanged(value); + } + return SimpleDialog( title: Text(widget.title), + insetPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 24), + constraints: const BoxConstraints(minWidth: 304), contentPadding: const EdgeInsets.symmetric(vertical: 8), children: [ if (toggle != null) - FocusableSwitchListTile( - value: _toggleValue, - secondary: toggle.icon != null ? AppIcon(toggle.icon!, fill: 1, size: 24) : null, - title: Text(toggle.label, style: Theme.of(context).textTheme.bodyLarge), - contentPadding: rowPadding, - onChanged: (value) { - setState(() => _toggleValue = value); - toggle.onChanged(value); - }, + MergeSemantics( + child: FocusableListTile( + title: Row( + children: [ + if (toggle.icon != null) ...[ + AppIcon(toggle.icon!, fill: 1, size: 24), + const SizedBox(width: rowHorizontalTitleGap), + ], + Expanded( + child: Text( + toggle.label, + style: Theme.of(context).textTheme.bodyLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: rowHorizontalTitleGap), + ExcludeFocus( + child: Switch(value: _toggleValue, onChanged: updateToggle), + ), + ], + ), + contentPadding: rowPadding, + onTap: () => updateToggle(!_toggleValue), + ), ), ...List.generate(widget.options.length, (index) { final option = widget.options[index]; @@ -413,6 +438,8 @@ class _OptionPickerDialogState extends State<_OptionPickerDialog> { leading: icon != null ? AppIcon(icon, fill: 1, size: 24) : null, title: Text(option.label, style: Theme.of(context).textTheme.bodyLarge), contentPadding: rowPadding, + horizontalTitleGap: rowHorizontalTitleGap, + minLeadingWidth: rowMinLeadingWidth, onTap: () async { if (widget.onBeforeClose != null) { final result = await widget.onBeforeClose!(option.value); diff --git a/lib/widgets/focusable_list_tile.dart b/lib/widgets/focusable_list_tile.dart index 8dfd742f..a6015ba5 100644 --- a/lib/widgets/focusable_list_tile.dart +++ b/lib/widgets/focusable_list_tile.dart @@ -44,6 +44,10 @@ class FocusableListTile extends StatefulWidget { final VisualDensity? visualDensity; + final double? horizontalTitleGap; + + final double? minLeadingWidth; + const FocusableListTile({ super.key, this.title, @@ -63,6 +67,8 @@ class FocusableListTile extends StatefulWidget { this.textColor, this.iconColor, this.visualDensity = const VisualDensity(vertical: -3), + this.horizontalTitleGap, + this.minLeadingWidth, }); @override @@ -125,6 +131,8 @@ class _FocusableListTileState extends State with FocusableTil hoverColor: widget.hoverColor, textColor: textColor, iconColor: iconColor, + horizontalTitleGap: widget.horizontalTitleGap, + minLeadingWidth: widget.minLeadingWidth, ), ); @@ -277,6 +285,12 @@ class FocusableSwitchListTile extends StatefulWidget { /// SwitchListTile default. final EdgeInsetsGeometry? contentPadding; + /// Horizontal gap between the leading/secondary widget and title. + final double? horizontalTitleGap; + + /// Minimum width reserved for the leading/secondary widget. + final double? minLeadingWidth; + const FocusableSwitchListTile({ super.key, this.title, @@ -289,6 +303,8 @@ class FocusableSwitchListTile extends StatefulWidget { this.autofocus = false, this.visualDensity = const VisualDensity(vertical: -3), this.contentPadding, + this.horizontalTitleGap, + this.minLeadingWidth, }); @override @@ -333,6 +349,8 @@ class _FocusableSwitchListTileState extends State contentPadding: widget.contentPadding, focusNode: effectiveFocusNode, autofocus: widget.autofocus, + horizontalTitleGap: widget.horizontalTitleGap, + minLeadingWidth: widget.minLeadingWidth, ), ); } diff --git a/test/widgets/option_picker_dialog_test.dart b/test/widgets/option_picker_dialog_test.dart new file mode 100644 index 00000000..d384ae9d --- /dev/null +++ b/test/widgets/option_picker_dialog_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:plezy/utils/dialogs.dart'; + +void main() { + testWidgets('toggle label stays on one line in narrow option picker dialog', (tester) async { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(320, 640); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + bool includeSpecials = true; + String? selected; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + return Scaffold( + body: TextButton( + onPressed: () async { + selected = await showOptionPickerDialog( + context, + title: 'Download', + toggle: ( + label: 'Include Specials', + icon: Symbols.star_rounded, + value: includeSpecials, + onChanged: (value) => includeSpecials = value, + ), + options: [ + (icon: Symbols.download_rounded, label: 'All Episodes', value: 'all'), + (icon: Symbols.visibility_off_rounded, label: 'Unwatched Only', value: 'unwatched'), + ], + ); + }, + child: const Text('Open'), + ), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + final includeSpecialsLabel = find.text('Include Specials'); + expect(includeSpecialsLabel, findsOneWidget); + + final paragraph = tester.renderObject(includeSpecialsLabel); + final lineBoxes = paragraph.getBoxesForSelection( + const TextSelection(baseOffset: 0, extentOffset: 'Include Specials'.length), + ); + expect(lineBoxes, hasLength(1)); + + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + + expect(includeSpecials, isFalse); + expect(selected, isNull); + expect(find.byType(SimpleDialog), findsOneWidget); + expect(includeSpecialsLabel, findsOneWidget); + + await tester.tap(find.text('All Episodes')); + await tester.pumpAndSettle(); + + expect(selected, 'all'); + expect(find.byType(SimpleDialog), findsNothing); + }); +}