From 43a8d08851a37bbb607b1eb26970ca76d2decd41 Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 29 Jul 2026 19:59:09 +0700 Subject: [PATCH] fix(download): make picker drag dismissal natural --- lib/widgets/app_bottom_sheet.dart | 33 ++++++++++++++ lib/widgets/download_service_picker.dart | 19 +++++--- test/design_system_test.dart | 58 ++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 6 deletions(-) diff --git a/lib/widgets/app_bottom_sheet.dart b/lib/widgets/app_bottom_sheet.dart index 558d8cac..ac1e57b3 100644 --- a/lib/widgets/app_bottom_sheet.dart +++ b/lib/widgets/app_bottom_sheet.dart @@ -40,6 +40,39 @@ class AppSheetHandle extends StatelessWidget { } } +/// A modal-sheet surface whose height and inner scroll position share one drag. +/// +/// The child must attach the supplied [ScrollController] to its primary +/// vertical scroll view. Pulling down at the top then moves the whole surface; +/// releasing either restores it or dismisses it at the minimum extent. +class AppDraggableSheet extends StatelessWidget { + const AppDraggableSheet({ + super.key, + required this.builder, + this.initialChildSize = 0.88, + this.minChildSize = 0.25, + this.maxChildSize = 0.88, + }); + + final ScrollableWidgetBuilder builder; + final double initialChildSize; + final double minChildSize; + final double maxChildSize; + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + expand: false, + snap: true, + shouldCloseOnMinExtent: true, + initialChildSize: initialChildSize, + minChildSize: minChildSize, + maxChildSize: maxChildSize, + builder: builder, + ); + } +} + /// Standard chrome for modal sheet content: drag handle, optional title block, /// height cap, keyboard inset and bottom safe area, so each sheet only supplies /// its own body. diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index 435eec06..6caaf705 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -13,6 +13,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget { final String? coverUrl; final void Function(String quality, String service) onSelect; final String? recommendedService; + final ScrollController? scrollController; const DownloadServicePicker({ super.key, @@ -21,6 +22,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget { this.coverUrl, required this.onSelect, this.recommendedService, + this.scrollController, }); @override @@ -42,12 +44,16 @@ class DownloadServicePicker extends ConsumerStatefulWidget { useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, isScrollControlled: true, - builder: (context) => DownloadServicePicker( - trackName: trackName, - artistName: artistName, - coverUrl: coverUrl, - onSelect: onSelect, - recommendedService: recommendedService, + enableDrag: false, + builder: (context) => AppDraggableSheet( + builder: (context, scrollController) => DownloadServicePicker( + trackName: trackName, + artistName: artistName, + coverUrl: coverUrl, + onSelect: onSelect, + recommendedService: recommendedService, + scrollController: scrollController, + ), ), ); } @@ -120,6 +126,7 @@ class _DownloadServicePickerState extends ConsumerState { return SafeArea( child: SingleChildScrollView( + controller: widget.scrollController, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, diff --git a/test/design_system_test.dart b/test/design_system_test.dart index 9ac41bc8..8bb7ed1b 100644 --- a/test/design_system_test.dart +++ b/test/design_system_test.dart @@ -259,6 +259,64 @@ void main() { expect(find.text('body'), findsOneWidget); }); + testWidgets('draggable content moves as one surface and dismisses', ( + tester, + ) async { + const sheetKey = ValueKey('scrollable-sheet'); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + // Disable the route recognizer so the draggable surface, + // rather than the modal's fallback gesture, is under test. + enableDrag: false, + builder: (_) => AppDraggableSheet( + builder: (_, scrollController) => Material( + key: sheetKey, + child: ListView( + controller: scrollController, + children: const [ + SizedBox(height: 800, child: Text('sheet body')), + ], + ), + ), + ), + ), + child: const Text('open'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect(find.byKey(sheetKey), findsOneWidget); + + final initialTop = tester.getTopLeft(find.byKey(sheetKey)).dy; + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(sheetKey)), + ); + await gesture.moveBy(const Offset(0, 160)); + await tester.pump(); + + expect( + tester.getTopLeft(find.byKey(sheetKey)).dy, + greaterThan(initialTop + 100), + ); + + await gesture.moveBy(const Offset(0, 260)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.byKey(sheetKey), findsNothing); + }); + testWidgets('sheet shape comes from the token scale', (tester) async { final shape = AppTheme.light().bottomSheetTheme.shape! as RoundedRectangleBorder;