From b540647222ca07ea8cc622b715b2bc90dff0720d Mon Sep 17 00:00:00 2001 From: My-Responsitories <107370289+My-Responsitories@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:37:45 +0000 Subject: [PATCH] feat: cross row select (#2437) * feat(wip): cross row select * fix patch Signed-off-by: dom * update Signed-off-by: dom --------- Co-authored-by: dom --- lib/pages/article/view.dart | 351 +++++++++--------- lib/pages/article/widgets/article_ops.dart | 2 +- lib/pages/article/widgets/html_render.dart | 24 +- lib/pages/article/widgets/opus_content.dart | 66 ++-- .../null_safety_for_selectable_region.patch | 249 +++++++++++++ lib/scripts/patch.ps1 | 6 +- 6 files changed, 472 insertions(+), 226 deletions(-) create mode 100644 lib/scripts/null_safety_for_selectable_region.patch diff --git a/lib/pages/article/view.dart b/lib/pages/article/view.dart index f404e1f5c..ac4dcc6b2 100644 --- a/lib/pages/article/view.dart +++ b/lib/pages/article/view.dart @@ -74,21 +74,27 @@ class _ArticlePageState extends CommonDynPageState { if (isPortrait) { return Padding( padding: EdgeInsets.symmetric(horizontal: padding), - child: CustomScrollView( - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - _buildContent( - maxWidth - this.padding.horizontal - 2 * padding - 24, - ), - SliverToBoxAdapter( - child: Divider( - thickness: 8, - color: theme.dividerColor.withValues(alpha: 0.05), + child: SelectionArea( + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + _buildContent( + maxWidth - this.padding.horizontal - 2 * padding - 24, ), - ), - buildReplyHeader(), - Obx(() => replyList(controller.loadingState.value)), - ], + SelectionContainer.disabled( + child: SliverToBoxAdapter( + child: Divider( + thickness: 8, + color: theme.dividerColor.withValues(alpha: 0.05), + ), + ), + ), + SelectionContainer.disabled(child: buildReplyHeader()), + SelectionContainer.disabled( + child: Obx(() => replyList(controller.loadingState.value)), + ), + ], + ), ), ); } @@ -101,21 +107,25 @@ class _ArticlePageState extends CommonDynPageState { children: [ Expanded( flex: flex, - child: CustomScrollView( - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - SliverPadding( - padding: EdgeInsets.only( - left: padding, - bottom: this.padding.bottom + 100, + child: SelectionArea( + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverPadding( + padding: EdgeInsets.only( + left: padding, + bottom: this.padding.bottom + 100, + ), + sliver: _buildContent( + (maxWidth - this.padding.horizontal) * + flex / + (flex + flex1) - + padding - + 32, + ), ), - sliver: _buildContent( - (maxWidth - this.padding.horizontal) * flex / (flex + flex1) - - padding - - 32, - ), - ), - ], + ], + ), ), ), VerticalDivider( @@ -151,7 +161,7 @@ class _ArticlePageState extends CommonDynPageState { sliver: Obx( () { if (controller.isLoaded.value) { - late Widget content; + final Widget content; if (controller.opus != null) { // if (kDebugMode) debugPrint('json page'); content = OpusContent( @@ -159,9 +169,9 @@ class _ArticlePageState extends CommonDynPageState { images: controller.images, maxWidth: maxWidth, ); - } else if (controller.opusData?.modules.moduleBlocked != null) { + } else if (controller.opusData?.modules.moduleBlocked + case final moduleBlocked?) { // if (kDebugMode) debugPrint('moduleBlocked'); - final moduleBlocked = controller.opusData!.modules.moduleBlocked!; content = SliverToBoxAdapter( child: moduleBlockedItem(context, theme, moduleBlocked), ); @@ -201,128 +211,119 @@ class _ArticlePageState extends CommonDynPageState { content = const SliverToBoxAdapter(child: Text('NULL')); } - int? pubTime = + final pubTime = controller.opusData?.modules.moduleAuthor?.pubTs ?? controller.articleData?.publishTime; return SliverMainAxisGroup( slivers: [ - if (controller.type != 'read' && - controller - .opusData - ?.modules - .moduleTop - ?.display - ?.album - ?.pics - ?.isNotEmpty == - true) - SliverToBoxAdapter( - child: Builder( - builder: (context) { - final pics = controller - .opusData! - .modules - .moduleTop! - .display! - .album! - .pics!; - final length = pics.length; - final first = pics.first; - double height; - if (first.height != null && first.width != null) { - final ratio = first.height! / first.width!; - height = min(maxWidth * ratio, maxHeight * 0.55); - } else { - height = maxHeight * 0.55; - } - return Stack( - clipBehavior: Clip.none, - children: [ - Container( - height: height, - width: maxWidth, - margin: const EdgeInsets.only(bottom: 10), - child: PageView.builder( - physics: clampingScrollPhysics, - onPageChanged: (value) => - controller.topIndex.value = value, - itemCount: length, - itemBuilder: (context, index) { - final pic = pics[index]; - int? memCacheWidth, memCacheHeight; - if (pic.isLongPic ?? false) { - memCacheWidth = maxWidth.cacheSize(context); - } else if (pic.width != null && - pic.height != null) { - if (pic.width! > pic.height!) { + if (controller.type != 'read') + if (controller.opusData?.modules.moduleTop?.display?.album?.pics + case final pics? when pics.isNotEmpty) + SliverToBoxAdapter( + child: Builder( + builder: (context) { + final length = pics.length; + final first = pics.first; + double height; + if (first.height != null && first.width != null) { + final ratio = first.height! / first.width!; + height = min(maxWidth * ratio, maxHeight * 0.55); + } else { + height = maxHeight * 0.55; + } + return Stack( + clipBehavior: Clip.none, + children: [ + Container( + height: height, + width: maxWidth, + margin: const EdgeInsets.only(bottom: 10), + child: PageView.builder( + physics: clampingScrollPhysics, + onPageChanged: (value) => + controller.topIndex.value = value, + itemCount: length, + itemBuilder: (context, index) { + final pic = pics[index]; + int? memCacheWidth, memCacheHeight; + if (pic.isLongPic ?? false) { memCacheWidth = maxWidth.cacheSize(context); - } else { - memCacheHeight = height.cacheSize(context); + } else if (pic.width != null && + pic.height != null) { + if (pic.width! > pic.height!) { + memCacheWidth = maxWidth.cacheSize( + context, + ); + } else { + memCacheHeight = height.cacheSize( + context, + ); + } } - } - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => PageUtils.imageView( - quality: 60, - imgList: pics - .map((e) => SourceModel(url: e.url!)) - .toList(), - initialPage: index, - ), - child: Hero( - tag: pic.url!, - child: Stack( - clipBehavior: Clip.none, - alignment: Alignment.center, - children: [ - CachedNetworkImage( - height: height, - width: maxWidth, - memCacheWidth: memCacheWidth, - memCacheHeight: memCacheHeight, - fit: pic.isLongPic == true - ? BoxFit.cover - : null, - imageUrl: ImageUtils.thumbnailUrl( - pic.url, - 60, - ), - fadeInDuration: const Duration( - milliseconds: 120, - ), - fadeOutDuration: const Duration( - milliseconds: 120, - ), - placeholder: (_, _) => - const SizedBox.shrink(), - ), - if (pic.isLongPic == true) - const PBadge( - right: 12, - bottom: 12, - text: '长图', - type: .primary, - ), - ], + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => PageUtils.imageView( + quality: 60, + imgList: pics + .map((e) => SourceModel(url: e.url!)) + .toList(), + initialPage: index, ), - ), - ); - }, + child: Hero( + tag: pic.url!, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + CachedNetworkImage( + height: height, + width: maxWidth, + memCacheWidth: memCacheWidth, + memCacheHeight: memCacheHeight, + fit: pic.isLongPic == true + ? BoxFit.cover + : null, + imageUrl: ImageUtils.thumbnailUrl( + pic.url, + 60, + ), + fadeInDuration: const Duration( + milliseconds: 120, + ), + fadeOutDuration: const Duration( + milliseconds: 120, + ), + placeholder: (_, _) => + const SizedBox.shrink(), + ), + if (pic.isLongPic == true) + const PBadge( + right: 12, + bottom: 12, + text: '长图', + type: .primary, + ), + ], + ), + ), + ); + }, + ), ), - ), - Obx( - () => PBadge( - top: 12, - right: 12, - type: PBadgeType.gray, - text: '${controller.topIndex.value + 1}/$length', + Obx( + () => PBadge( + top: 12, + right: 12, + type: PBadgeType.gray, + text: + '${controller.topIndex.value + 1}/$length', + ), ), - ), - ], - ); - }, + ], + ); + }, + ), ), - ), if (controller.summary.title != null) SliverToBoxWithVisibilityAdapter( onVisibilityChanged: (bool visible) => @@ -342,39 +343,41 @@ class _ArticlePageState extends CommonDynPageState { onTap: () => Get.toNamed( '/member?mid=${controller.summary.author?.mid}', ), - child: Row( - children: [ - NetworkImgLayer( - width: 40, - height: 40, - type: ImageType.avatar, - src: controller.summary.author?.face, - ), - const SizedBox(width: 10), - Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - controller.summary.author?.name ?? '', - style: TextStyle( - fontSize: - theme.textTheme.titleSmall!.fontSize, - ), - ), - if (pubTime != null) + child: SelectionContainer.disabled( + child: Row( + children: [ + NetworkImgLayer( + width: 40, + height: 40, + type: ImageType.avatar, + src: controller.summary.author?.face, + ), + const SizedBox(width: 10), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Text( - DateFormatUtils.format(pubTime), + controller.summary.author?.name ?? '', style: TextStyle( - color: theme.colorScheme.outline, fontSize: - theme.textTheme.labelSmall!.fontSize, + theme.textTheme.titleSmall!.fontSize, ), ), - ], + if (pubTime != null) + Text( + DateFormatUtils.format(pubTime), + style: TextStyle( + color: theme.colorScheme.outline, + fontSize: + theme.textTheme.labelSmall!.fontSize, + ), + ), + ], + ), ), - ), - ], + ], + ), ), ), ), @@ -382,9 +385,11 @@ class _ArticlePageState extends CommonDynPageState { if (controller.type != 'read' && controller.opusData?.modules.moduleCollection != null) SliverToBoxAdapter( - child: opusCollection( - theme, - controller.opusData!.modules.moduleCollection!, + child: SelectionContainer.disabled( + child: opusCollection( + theme, + controller.opusData!.modules.moduleCollection!, + ), ), ), content, diff --git a/lib/pages/article/widgets/article_ops.dart b/lib/pages/article/widgets/article_ops.dart index 943520e78..92271d096 100644 --- a/lib/pages/article/widgets/article_ops.dart +++ b/lib/pages/article/widgets/article_ops.dart @@ -33,7 +33,7 @@ class ArticleOpus extends StatelessWidget { final item = _ops[index]; switch (item.insert) { case String e: - return SelectableText(e); + return Text(e); case Insert(:final card): if (card != null) { if (card.url?.isNotEmpty == true) { diff --git a/lib/pages/article/widgets/html_render.dart b/lib/pages/article/widgets/html_render.dart index 68b27fa68..fd5831315 100644 --- a/lib/pages/article/widgets/html_render.dart +++ b/lib/pages/article/widgets/html_render.dart @@ -125,17 +125,15 @@ Widget htmlRender({ margin: Margins.zero, ), }; - return SelectionArea( - child: element != null - ? Html.fromElement( - documentElement: element, - extensions: extensions, - style: style, - ) - : Html( - data: html, - extensions: extensions, - style: style, - ), - ); + return element != null + ? Html.fromElement( + documentElement: element, + extensions: extensions, + style: style, + ) + : Html( + data: html, + extensions: extensions, + style: style, + ); } diff --git a/lib/pages/article/widgets/opus_content.dart b/lib/pages/article/widgets/opus_content.dart index e6257447b..6c951d187 100644 --- a/lib/pages/article/widgets/opus_content.dart +++ b/lib/pages/article/widgets/opus_content.dart @@ -178,7 +178,7 @@ class OpusContent extends StatelessWidget { switch (element.paraType) { case 1 || 4: final isQuote = element.paraType == 4; - Widget widget = SelectableText.rich( + Widget widget = Text.rich( textAlign: element.align == 1 ? TextAlign.center : null, TextSpan( children: element.text?.nodes @@ -194,12 +194,7 @@ class OpusContent extends StatelessWidget { ); if (isQuote) { widget = Container( - padding: const EdgeInsets.only( - left: 8, - top: 4, - right: 4, - bottom: 4, - ), + padding: const .only(left: 8, top: 4, right: 4, bottom: 4), decoration: BoxDecoration( border: Border( left: BorderSide( @@ -215,8 +210,9 @@ class OpusContent extends StatelessWidget { } return widget; case 2 when (element.pic != null): - if (element.pic!.pics!.length == 1) { - final pic = element.pic!.pics!.first; + final pics = element.pic!.pics!; + if (pics.length == 1) { + final pic = pics.first; double? width = pic.width == null ? null : math.min(maxWidth, pic.width!); @@ -250,7 +246,7 @@ class OpusContent extends StatelessWidget { ); } else { return ImageGridView( - picArr: element.pic!.pics! + picArr: pics .map( (e) => ImageModel( width: e.width, @@ -262,19 +258,20 @@ class OpusContent extends StatelessWidget { ); } case 3 when (element.line?.pic != null): - final height = element.line!.pic!.height?.toDouble(); + final pic = element.line!.pic!; + final height = pic.height?.toDouble(); return CachedNetworkImage( fit: .contain, height: height, width: maxWidth, memCacheWidth: maxWidth.cacheSize(context), - imageUrl: ImageUtils.thumbnailUrl(element.line!.pic!.url!), + imageUrl: ImageUtils.thumbnailUrl(pic.url!), placeholder: (_, _) => const SizedBox.shrink(), ); - case 5 when (element.list != null): - return SelectableText.rich( + case 5 when (element.list?.items?.isNotEmpty == true): + return Text.rich( TextSpan( - children: element.list!.items?.mapIndexed((i, entry) { + children: element.list!.items!.mapIndexed((i, entry) { return TextSpan( children: [ const WidgetSpan( @@ -589,39 +586,32 @@ class OpusContent extends StatelessWidget { ? null : () { try { + final card = element.linkCard!.card!; if (type == 'LINK_CARD_TYPE_VOTE') { showVoteDialog( context, - element.linkCard!.card!.vote?.voteId ?? - int.parse(element.linkCard!.card!.oid!), + card.vote?.voteId ?? int.parse(card.oid!), ); return; } if (type == 'LINK_CARD_TYPE_ITEM_NULL') { - switch (element.linkCard?.card?.itemNull?.text) { + switch (card.itemNull?.text) { case '视频': PiliScheme.videoPush( - int.parse(element.linkCard!.card!.oid!), + int.parse(card.oid!), null, ); default: - PageUtils.pushDynFromId( - id: element.linkCard!.card!.oid!, - ); + PageUtils.pushDynFromId(id: card.oid!); } return; } - String? url = switch (type) { - 'LINK_CARD_TYPE_UGC' => - element.linkCard!.card!.ugc!.jumpUrl, - 'LINK_CARD_TYPE_COMMON' => - element.linkCard!.card!.common!.jumpUrl, - 'LINK_CARD_TYPE_LIVE' => - element.linkCard!.card!.live!.jumpUrl, - 'LINK_CARD_TYPE_OPUS' => - element.linkCard!.card!.opus!.jumpUrl, - 'LINK_CARD_TYPE_MUSIC' => - element.linkCard!.card!.music!.jumpUrl, + final url = switch (type) { + 'LINK_CARD_TYPE_UGC' => card.ugc!.jumpUrl, + 'LINK_CARD_TYPE_COMMON' => card.common!.jumpUrl, + 'LINK_CARD_TYPE_LIVE' => card.live!.jumpUrl, + 'LINK_CARD_TYPE_OPUS' => card.opus!.jumpUrl, + 'LINK_CARD_TYPE_MUSIC' => card.music!.jumpUrl, _ => null, }; if (url != null && url.isNotEmpty) { @@ -660,10 +650,10 @@ class OpusContent extends StatelessWidget { color: colorScheme.onInverseSurface, ), width: .infinity, - child: SelectableText.rich(renderer.span!), + child: Text.rich(renderer.span!), ); case 8 when (element.heading?.nodes?.isNotEmpty == true): - return SelectableText.rich( + return Text.rich( TextSpan( children: element.heading!.nodes! .map( @@ -679,7 +669,7 @@ class OpusContent extends StatelessWidget { default: if (kDebugMode) debugPrint('unknown type ${element.paraType}'); if (element.text?.nodes?.isNotEmpty == true) { - return SelectableText.rich( + return Text.rich( textAlign: element.align == 1 ? TextAlign.center : null, TextSpan( children: element.text!.nodes! @@ -694,7 +684,7 @@ class OpusContent extends StatelessWidget { ); } - return SelectableText( + return Text( '不支持的类型 (${element.paraType})', style: const TextStyle( fontWeight: .bold, @@ -703,7 +693,7 @@ class OpusContent extends StatelessWidget { ); } } catch (e, s) { - return SelectableText( + return Text( '错误的类型 $e${kDebugMode ? '\n$s' : ''}', style: const TextStyle( fontWeight: .bold, diff --git a/lib/scripts/null_safety_for_selectable_region.patch b/lib/scripts/null_safety_for_selectable_region.patch new file mode 100644 index 000000000..7ad0b6bd6 --- /dev/null +++ b/lib/scripts/null_safety_for_selectable_region.patch @@ -0,0 +1,249 @@ +diff --git a/packages/flutter/lib/src/widgets/selectable_region.dart b/packages/flutter/lib/src/widgets/selectable_region.dart +index 59de8bae20b..5a6923f3a6a 100644 +--- a/packages/flutter/lib/src/widgets/selectable_region.dart ++++ b/packages/flutter/lib/src/widgets/selectable_region.dart +@@ -29,8 +29,10 @@ import 'framework.dart'; + import 'gesture_detector.dart'; + import 'magnifier.dart'; + import 'media_query.dart'; ++import 'notification_listener.dart'; + import 'overlay.dart'; + import 'platform_selectable_region_context_menu.dart'; ++import 'scroll_notification.dart'; + import 'selection_container.dart'; + import 'tap_region.dart'; + import 'text_editing_intents.dart'; +@@ -1775,12 +1777,12 @@ class SelectableRegionState extends State + + /// The line height at the start of the current selection. + double get startGlyphHeight { +- return _selectionDelegate.value.startSelectionPoint!.lineHeight; ++ return _selectionDelegate.value.startSelectionPoint?.lineHeight ?? 0; + } + + /// The line height at the end of the current selection. + double get endGlyphHeight { +- return _selectionDelegate.value.endSelectionPoint!.lineHeight; ++ return _selectionDelegate.value.endSelectionPoint?.lineHeight ?? 0; + } + + /// Returns the local coordinates of the endpoints of the current selection. +@@ -1946,7 +1948,7 @@ class SelectableRegionState extends State + if (_webContextMenuEnabled) { + result = PlatformSelectableRegionContextMenu(child: result); + } +- return TapRegion( ++ final tapRegion = TapRegion( + groupId: SelectableRegion, + onTapOutside: (PointerDownEvent event) { + // To match the native web behavior, this selectable region is +@@ -1976,6 +1978,22 @@ class SelectableRegionState extends State + ), + ), + ); ++ return NotificationListener( ++ onNotification: (notification) { ++ _selectionDelegate.layoutDidChange(); ++ _updateSelectedContentIfNeeded(); ++ final SelectedContent? lastSelection = _lastSelectedContent; ++ if (lastSelection != null && lastSelection.plainText.isNotEmpty) { ++ _showHandles(); ++ final SelectionOverlay? selectionOverlay = _selectionOverlay; ++ if (selectionOverlay == null || !selectionOverlay.toolbarIsVisible) { ++ _showToolbar(); ++ } ++ } ++ return false; ++ }, ++ child: tapRegion, ++ ); + } + } + +diff --git a/packages/flutter/test/widgets/selectable_region_test.dart b/packages/flutter/test/widgets/selectable_region_test.dart +index f7bd4071c8a..a82a9448120 100644 +--- a/packages/flutter/test/widgets/selectable_region_test.dart ++++ b/packages/flutter/test/widgets/selectable_region_test.dart +@@ -841,6 +841,125 @@ void main() { + expect(renderSelectionSpy.events.length, 0); + }); + ++ testWidgets( ++ 'SelectionArea + ListView: select across multiple lines and scroll does not crash', ++ (WidgetTester tester) async { ++ // Regression test: when selection spans across ListView items and user scrolls, ++ // startSelectionPoint/endSelectionPoint may be null (off-screen). Verifies that ++ // startGlyphHeight/endGlyphHeight return 0 and do not crash. ++ await tester.pumpWidget( ++ MaterialApp( ++ home: SelectableRegion( ++ selectionControls: materialTextSelectionControls, ++ child: SizedBox( ++ height: 400, ++ child: ListView( ++ children: List.generate( ++ 15, ++ (int i) => Text( ++ 'Line $i: Selectable text content for testing', ++ style: const TextStyle(fontSize: 20), ++ ), ++ ), ++ ), ++ ), ++ ), ++ ), ++ ); ++ await tester.pumpAndSettle(); ++ ++ final RenderParagraph firstParagraph = tester.renderObject( ++ find.descendant( ++ of: find.text('Line 0: Selectable text content for testing'), ++ matching: find.byType(RichText), ++ ), ++ ); ++ final RenderParagraph thirdParagraph = tester.renderObject( ++ find.descendant( ++ of: find.text('Line 2: Selectable text content for testing'), ++ matching: find.byType(RichText), ++ ), ++ ); ++ ++ // Select from first line to third line (cross-line selection). ++ final TestGesture selectGesture = await tester.startGesture( ++ textOffsetToPosition(firstParagraph, 2), ++ kind: PointerDeviceKind.mouse, ++ ); ++ addTearDown(selectGesture.removePointer); ++ await tester.pump(); ++ await selectGesture.moveTo(textOffsetToPosition(thirdParagraph, 15)); ++ await tester.pump(); ++ await selectGesture.up(); ++ await tester.pumpAndSettle(); ++ ++ // Verify selection exists. ++ expect(firstParagraph.selections.isNotEmpty, isTrue); ++ expect(thirdParagraph.selections.isNotEmpty, isTrue); ++ ++ // Simulate scroll - this may cause selection points to become null when off-screen. ++ await tester.drag(find.byType(ListView), const Offset(0, -150)); ++ await tester.pumpAndSettle(); ++ ++ // Should not throw - startGlyphHeight/endGlyphHeight return 0 when points are null. ++ expect(tester.takeException(), isNull); ++ }, ++ skip: kIsWeb, // [intended] Web uses its native context menu. ++ ); ++ ++ testWidgets( ++ 'startGlyphHeight and endGlyphHeight return 0 when startSelectionPoint/endSelectionPoint are null', ++ (WidgetTester tester) async { ++ // Directly tests the fix: when SelectionGeometry has null start/end points ++ // (e.g. selection off-screen in scrollable), startGlyphHeight and ++ // endGlyphHeight must not crash and must return 0. ++ final delegateKey = GlobalKey(); ++ ++ await tester.pumpWidget( ++ MaterialApp( ++ home: SelectableRegion( ++ selectionControls: materialTextSelectionControls, ++ child: NullSelectionPointDelegateWidget( ++ key: delegateKey, ++ child: const Column( ++ children: [Text('First'), Text('Second'), Text('Third')], ++ ), ++ ), ++ ), ++ ), ++ ); ++ await tester.pumpAndSettle(); ++ ++ final RenderParagraph paragraph = tester.renderObject( ++ find.descendant(of: find.text('Second'), matching: find.byType(RichText)), ++ ); ++ final TestGesture gesture = await tester.startGesture( ++ textOffsetToPosition(paragraph, 2), ++ kind: PointerDeviceKind.mouse, ++ ); ++ addTearDown(gesture.removePointer); ++ await gesture.moveTo(textOffsetToPosition(paragraph, 5)); ++ await gesture.up(); ++ await tester.pumpAndSettle(); ++ ++ // Switch delegate to return null selection points (simulates off-screen scenario). ++ final NullSelectionPointDelegate delegate = delegateKey.currentState!.delegate; ++ delegate.useNullSelectionPoints = true; ++ delegate.notifyListeners(); ++ await tester.pump(); ++ ++ final SelectableRegionState state = tester.state( ++ find.byType(SelectableRegion), ++ ); ++ // Access the getters - should not throw, must return 0 when points are null. ++ expect(() => state.startGlyphHeight, returnsNormally); ++ expect(() => state.endGlyphHeight, returnsNormally); ++ expect(state.startGlyphHeight, 0); ++ expect(state.endGlyphHeight, 0); ++ }, ++ skip: kIsWeb, // [intended] Web uses its native context menu. ++ ); ++ + testWidgets('mouse long press does not send select-word event', (WidgetTester tester) async { + final spy = UniqueKey(); + +@@ -6690,6 +6809,56 @@ void main() { + }); + } + ++/// A [SelectionContainerDelegate] that can return [SelectionGeometry] with null ++/// [startSelectionPoint] and [endSelectionPoint] to test off-screen selection handling. ++class NullSelectionPointDelegate extends StaticSelectionContainerDelegate { ++ bool useNullSelectionPoints = false; ++ ++ @override ++ SelectionGeometry get value { ++ final SelectionGeometry geometry = super.value; ++ if (useNullSelectionPoints && geometry.hasSelection) { ++ return SelectionGeometry( ++ status: geometry.status, ++ hasContent: geometry.hasContent, ++ selectionRects: geometry.selectionRects, ++ ); ++ } ++ return geometry; ++ } ++} ++ ++/// Widget that holds [NullSelectionPointDelegate] for testing. ++class NullSelectionPointDelegateWidget extends StatefulWidget { ++ const NullSelectionPointDelegateWidget({super.key, required this.child}); ++ ++ final Widget child; ++ ++ @override ++ State createState() => NullSelectionPointDelegateWidgetState(); ++} ++ ++class NullSelectionPointDelegateWidgetState extends State { ++ late final NullSelectionPointDelegate delegate; ++ ++ @override ++ void initState() { ++ super.initState(); ++ delegate = NullSelectionPointDelegate(); ++ } ++ ++ @override ++ void dispose() { ++ delegate.dispose(); ++ super.dispose(); ++ } ++ ++ @override ++ Widget build(BuildContext context) { ++ return SelectionContainer(delegate: delegate, child: widget.child); ++ } ++} ++ + class ColumnSelectionContainerDelegate extends StaticSelectionContainerDelegate { + /// Copies the selected contents of all [Selectable]s, separating their + /// contents with a new line. diff --git a/lib/scripts/patch.ps1 b/lib/scripts/patch.ps1 index 7bd0182bf..f72c1d29f 100644 --- a/lib/scripts/patch.ps1 +++ b/lib/scripts/patch.ps1 @@ -33,6 +33,10 @@ $PopupMenuPatch = "lib/scripts/popup_menu.patch" $FABPatch = "lib/scripts/fab.patch" +# TODO: remove +# https://github.com/flutter/flutter/pull/183261 +$SelectableRegionPatch = "lib/scripts/null_safety_for_selectable_region.patch" + # TODO: remove # https://github.com/flutter/flutter/issues/90223 $ModalBarrierPatch = "lib/scripts/modal_barrier.patch" @@ -60,7 +64,7 @@ $picks = @() $reverts = @() $patches = @($ModalBarrierPatch, $TextSelectionPatch, $MouseCursorPatch, $ImageAnimPatch, $LayoutBuilderPatch, $NavigationDrawerPatch, - $PopupMenuPatch, $FABPatch) + $PopupMenuPatch, $FABPatch, $SelectableRegionPatch) switch ($platform.ToLower()) { "android" {