mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 14:37:33 +02:00
fix(library): collapse selection actions before dismissing
Keep selection active after the first downward swipe, and cancel only on a second swipe from the compact header. Support expansion by dragging or tapping, pin the header while actions scroll, and cover large text and accessibility behavior.
This commit is contained in:
1 parent
58fb7750e5
commit
c093120e73
2 files changed
+408
-98
No files matched your search
@@ -244,72 +244,62 @@ class SelectionBottomBar extends StatelessWidget {
|
||||
final tokens = context.tokens;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final content = SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const AppSheetHandle(),
|
||||
final header = LayoutBuilder(
|
||||
builder: (context, constraints) => Row(
|
||||
children: [
|
||||
if (context.isMornye)
|
||||
Tooltip(
|
||||
message: MaterialLocalizations.of(context).closeButtonTooltip,
|
||||
child: CupertinoButton(
|
||||
color: MornyeTheme.controlFill(context),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
padding: const EdgeInsets.all(12),
|
||||
onPressed: onClose,
|
||||
child: Icon(
|
||||
CupertinoIcons.xmark,
|
||||
color: colorScheme.onSurface,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
IconButton.filledTonal(
|
||||
onPressed: onClose,
|
||||
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
|
||||
icon: const Icon(Icons.close),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
Row(
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (context.isMornye)
|
||||
Tooltip(
|
||||
message: MaterialLocalizations.of(
|
||||
context,
|
||||
).closeButtonTooltip,
|
||||
child: CupertinoButton(
|
||||
color: MornyeTheme.controlFill(context),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
padding: const EdgeInsets.all(12),
|
||||
onPressed: onClose,
|
||||
child: Icon(
|
||||
CupertinoIcons.xmark,
|
||||
color: colorScheme.onSurface,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
IconButton.filledTonal(
|
||||
onPressed: onClose,
|
||||
tooltip: MaterialLocalizations.of(
|
||||
context,
|
||||
).closeButtonTooltip,
|
||||
icon: const Icon(Icons.close),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.selectionSelected(selectedCount),
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
allSelected
|
||||
? allSelectedLabel ??
|
||||
context.l10n.selectionAllSelected
|
||||
: tapToSelectLabel ??
|
||||
context.l10n.downloadedAlbumTapToSelect,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
Text(
|
||||
context.l10n.selectionSelected(selectedCount),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
allSelected
|
||||
? allSelectedLabel ?? context.l10n.selectionAllSelected
|
||||
: tapToSelectLabel ??
|
||||
context.l10n.downloadedAlbumTapToSelect,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (context.isMornye)
|
||||
CupertinoButton(
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: constraints.maxWidth * 0.4),
|
||||
child: context.isMornye
|
||||
? CupertinoButton(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
onPressed: onToggleSelectAll,
|
||||
child: Text(
|
||||
@@ -321,8 +311,7 @@ class SelectionBottomBar extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
TextButton.icon(
|
||||
: TextButton.icon(
|
||||
onPressed: onToggleSelectAll,
|
||||
icon: Icon(
|
||||
allSelected ? Icons.deselect : Icons.select_all,
|
||||
@@ -337,33 +326,18 @@ class SelectionBottomBar extends StatelessWidget {
|
||||
foregroundColor: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
...children,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return _SelectionPanelDrag(
|
||||
onClose: onClose,
|
||||
builder: (scrollController) {
|
||||
final boundedContent = ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.72,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(
|
||||
parent: ClampingScrollPhysics(),
|
||||
),
|
||||
child: content,
|
||||
),
|
||||
);
|
||||
header: header,
|
||||
bottomPadding: bottomPadding,
|
||||
children: children,
|
||||
surfaceBuilder: (content) {
|
||||
if (context.isMornye) {
|
||||
return MornyeGlassPanel.overlay(child: boundedContent);
|
||||
return MornyeGlassPanel.overlay(child: content);
|
||||
}
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
@@ -379,45 +353,100 @@ class SelectionBottomBar extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
child: boundedContent,
|
||||
child: content,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shares the scrollable's gesture: downward motion at its top pulls the whole
|
||||
/// surface instead of bouncing the header inside a stationary glass panel.
|
||||
/// Keeps selection alive when the actions are tucked away. One downward
|
||||
/// gesture collapses the actions; only a new gesture on the compact header
|
||||
/// dismisses selection. Action scrolling never hides the header.
|
||||
class _SelectionPanelDrag extends StatefulWidget {
|
||||
const _SelectionPanelDrag({required this.onClose, required this.builder});
|
||||
const _SelectionPanelDrag({
|
||||
required this.onClose,
|
||||
required this.header,
|
||||
required this.bottomPadding,
|
||||
required this.children,
|
||||
required this.surfaceBuilder,
|
||||
});
|
||||
|
||||
final VoidCallback onClose;
|
||||
final Widget Function(ScrollController) builder;
|
||||
final Widget header;
|
||||
final double bottomPadding;
|
||||
final List<Widget> children;
|
||||
final Widget Function(Widget) surfaceBuilder;
|
||||
|
||||
@override
|
||||
State<_SelectionPanelDrag> createState() => _SelectionPanelDragState();
|
||||
}
|
||||
|
||||
class _SelectionPanelDragState extends State<_SelectionPanelDrag>
|
||||
with SingleTickerProviderStateMixin {
|
||||
with TickerProviderStateMixin {
|
||||
late final AnimationController _offset = AnimationController.unbounded(
|
||||
vsync: this,
|
||||
);
|
||||
late final _SelectionPanelScrollController _scroll =
|
||||
_SelectionPanelScrollController(_drag, _settle);
|
||||
late final AnimationController _expansion = AnimationController(
|
||||
vsync: this,
|
||||
value: 1,
|
||||
duration: const Duration(milliseconds: 220),
|
||||
);
|
||||
final _actionsKey = GlobalKey();
|
||||
bool _expanded = true;
|
||||
bool? _dragFromExpanded;
|
||||
double _pull = 0;
|
||||
double _actionHeight = 1;
|
||||
bool _closing = false;
|
||||
|
||||
double _drag(double delta) {
|
||||
if (_closing) return 0;
|
||||
if (_dragFromExpanded == null) {
|
||||
if (_expanded && _expansion.value == 1 && delta <= 0) return delta;
|
||||
_dragFromExpanded = _expanded;
|
||||
_actionHeight = (_actionsKey.currentContext?.size?.height ?? 1).clamp(
|
||||
1.0,
|
||||
double.infinity,
|
||||
);
|
||||
_pull = _expanded
|
||||
? (1 - _expansion.value) * _actionHeight
|
||||
: _offset.value - _expansion.value * _actionHeight;
|
||||
}
|
||||
_offset.stop();
|
||||
final previous = _offset.value;
|
||||
_offset.value = (previous + delta).clamp(0.0, double.infinity);
|
||||
return delta - (_offset.value - previous);
|
||||
_expansion.stop();
|
||||
if (_dragFromExpanded!) {
|
||||
final previous = _pull;
|
||||
_pull = (_pull + delta).clamp(0.0, _actionHeight);
|
||||
_expansion.value = 1 - _pull / _actionHeight;
|
||||
// A single pull can reach the compact state, but cannot cancel it.
|
||||
return delta < 0 ? delta - (_pull - previous) : 0;
|
||||
}
|
||||
_pull = (_pull + delta).clamp(-_actionHeight, double.infinity);
|
||||
_expansion.value = (-_pull / _actionHeight).clamp(0.0, 1.0);
|
||||
_offset.value = _pull.clamp(0.0, double.infinity);
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool _settle(double velocity) {
|
||||
if (_closing) return true;
|
||||
if (_offset.value == 0) return false;
|
||||
final fromExpanded = _dragFromExpanded;
|
||||
if (fromExpanded == null) return false;
|
||||
_dragFromExpanded = null;
|
||||
final threshold = (_actionHeight * 0.25).clamp(64.0, 120.0);
|
||||
if (fromExpanded) {
|
||||
final collapse =
|
||||
velocity > 700 || (velocity >= -700 && _pull >= threshold);
|
||||
_setExpanded(!collapse);
|
||||
return true;
|
||||
}
|
||||
if (_pull < 0) {
|
||||
final expand =
|
||||
velocity < -700 || (velocity <= 700 && -_pull >= threshold);
|
||||
_setExpanded(expand);
|
||||
return true;
|
||||
}
|
||||
final height = context.size?.height ?? 0;
|
||||
_closing =
|
||||
velocity > 700 ||
|
||||
@@ -426,6 +455,31 @@ class _SelectionPanelDragState extends State<_SelectionPanelDrag>
|
||||
return true;
|
||||
}
|
||||
|
||||
void _setExpanded(bool expanded) {
|
||||
setState(() => _expanded = expanded);
|
||||
if (!expanded && _scroll.hasClients) _scroll.jumpTo(0);
|
||||
_expansion.animateTo(
|
||||
expanded ? 1 : 0,
|
||||
duration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
_animateToRest(0);
|
||||
}
|
||||
|
||||
void _toggleExpanded() {
|
||||
if (_closing) return;
|
||||
_dragFromExpanded = null;
|
||||
_setExpanded(!_expanded);
|
||||
}
|
||||
|
||||
void _cancelDrag() {
|
||||
final fromExpanded = _dragFromExpanded;
|
||||
_dragFromExpanded = null;
|
||||
if (fromExpanded != null) _setExpanded(fromExpanded);
|
||||
}
|
||||
|
||||
Future<void> _animateToRest(double target) async {
|
||||
try {
|
||||
await _offset
|
||||
@@ -445,6 +499,7 @@ class _SelectionPanelDragState extends State<_SelectionPanelDrag>
|
||||
void dispose() {
|
||||
_scroll.dispose();
|
||||
_offset.dispose();
|
||||
_expansion.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -453,7 +508,90 @@ class _SelectionPanelDragState extends State<_SelectionPanelDrag>
|
||||
animation: _offset,
|
||||
builder: (_, child) =>
|
||||
Transform.translate(offset: Offset(0, _offset.value), child: child),
|
||||
child: widget.builder(_scroll),
|
||||
child: widget.surfaceBuilder(
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.72,
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
widget.bottomPadding > 0 ? 8 : 16,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onVerticalDragUpdate: (details) => _drag(details.delta.dy),
|
||||
onVerticalDragEnd: (details) =>
|
||||
_settle(details.primaryVelocity ?? 0),
|
||||
onVerticalDragCancel: _cancelDrag,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Semantics(
|
||||
container: true,
|
||||
button: true,
|
||||
expanded: _expanded,
|
||||
label: _expanded
|
||||
? MaterialLocalizations.of(
|
||||
context,
|
||||
).expandedIconTapHint
|
||||
: MaterialLocalizations.of(
|
||||
context,
|
||||
).collapsedIconTapHint,
|
||||
onTap: _toggleExpanded,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
excludeFromSemantics: true,
|
||||
onTap: _toggleExpanded,
|
||||
child: const SizedBox(
|
||||
height: 32,
|
||||
child: AppSheetHandle(margin: EdgeInsets.zero),
|
||||
),
|
||||
),
|
||||
),
|
||||
widget.header,
|
||||
],
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: ExcludeSemantics(
|
||||
excluding: !_expanded,
|
||||
child: IgnorePointer(
|
||||
ignoring: !_expanded,
|
||||
child: SizeTransition(
|
||||
sizeFactor: _expansion,
|
||||
alignment: Alignment.topCenter,
|
||||
child: SingleChildScrollView(
|
||||
key: _actionsKey,
|
||||
controller: _scroll,
|
||||
physics: const AlwaysScrollableScrollPhysics(
|
||||
parent: ClampingScrollPhysics(),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
...widget.children,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,10 +15,18 @@ void main() {
|
||||
WidgetTester tester,
|
||||
VoidCallback onClose, {
|
||||
bool tall = false,
|
||||
int selectedCount = 1,
|
||||
double textScale = 1,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
child: MaterialApp(
|
||||
builder: (context, child) => MediaQuery(
|
||||
data: MediaQuery.of(
|
||||
context,
|
||||
).copyWith(textScaler: TextScaler.linear(textScale)),
|
||||
child: child!,
|
||||
),
|
||||
theme: mornye
|
||||
? MornyeTheme.build(Brightness.light)
|
||||
: AppTheme.light(),
|
||||
@@ -31,7 +39,7 @@ void main() {
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: SelectionBottomBar(
|
||||
selectedCount: 1,
|
||||
selectedCount: selectedCount,
|
||||
allSelected: true,
|
||||
onClose: onClose,
|
||||
onToggleSelectAll: () {},
|
||||
@@ -54,13 +62,16 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('$name: downward drag moves the panel and closes once', (
|
||||
testWidgets('$name: first swipe collapses and second swipe closes once', (
|
||||
tester,
|
||||
) async {
|
||||
var closed = 0;
|
||||
await showPanel(tester, () => closed++);
|
||||
final surface = find.byType(SingleChildScrollView);
|
||||
final initial = tester.getTopLeft(surface);
|
||||
final expandedHeight = tester
|
||||
.getSize(find.byType(SelectionBottomBar))
|
||||
.height;
|
||||
final handle = find.byType(AppSheetHandle);
|
||||
final gesture = await tester.startGesture(tester.getCenter(handle));
|
||||
await gesture.moveBy(const Offset(0, 25));
|
||||
@@ -76,12 +87,109 @@ void main() {
|
||||
expect(background.position.pixels, 0);
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
expect(closed, 0);
|
||||
expect(find.text('1 selected'), findsOneWidget);
|
||||
expect(find.text('Action').hitTestable(), findsNothing);
|
||||
expect(
|
||||
tester.getSize(find.byType(SelectionBottomBar)).height,
|
||||
lessThan(expandedHeight - 100),
|
||||
);
|
||||
await tester.drag(handle, const Offset(0, 150));
|
||||
await tester.pumpAndSettle();
|
||||
expect(closed, 1);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
expect(closed, 1);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('$name: a long first swipe cannot cancel selection', (
|
||||
tester,
|
||||
) async {
|
||||
var closed = 0;
|
||||
await showPanel(tester, () => closed++);
|
||||
await tester.drag(find.byType(AppSheetHandle), const Offset(0, 600));
|
||||
await tester.pumpAndSettle();
|
||||
expect(closed, 0);
|
||||
expect(find.text('Action').hitTestable(), findsNothing);
|
||||
expect(find.text('1 selected'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('$name: collapsed panel stays compact as selection changes', (
|
||||
tester,
|
||||
) async {
|
||||
var closed = 0;
|
||||
await showPanel(tester, () => closed++);
|
||||
await tester.drag(find.byType(AppSheetHandle), const Offset(0, 150));
|
||||
await tester.pumpAndSettle();
|
||||
final compactHeight = tester
|
||||
.getSize(find.byType(SelectionBottomBar))
|
||||
.height;
|
||||
|
||||
await showPanel(tester, () => closed++, selectedCount: 9);
|
||||
|
||||
expect(find.text('9 selected'), findsOneWidget);
|
||||
expect(closed, 0);
|
||||
expect(find.text('Action').hitTestable(), findsNothing);
|
||||
expect(
|
||||
tester.getSize(find.byType(SelectionBottomBar)).height,
|
||||
compactHeight,
|
||||
);
|
||||
// The area occupied by the expanded panel must belong to Library again.
|
||||
await tester.timedDragFrom(
|
||||
tester.getTopLeft(find.byType(SelectionBottomBar)) -
|
||||
const Offset(-100, 50),
|
||||
const Offset(0, -100),
|
||||
const Duration(seconds: 1),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final background = tester.state<ScrollableState>(
|
||||
find.descendant(
|
||||
of: find.byType(ListView),
|
||||
matching: find.byType(Scrollable),
|
||||
),
|
||||
);
|
||||
expect(background.position.pixels, greaterThan(80));
|
||||
expect(closed, 0);
|
||||
});
|
||||
|
||||
testWidgets('$name: tap handle and upward swipe restore actions', (
|
||||
tester,
|
||||
) async {
|
||||
var closed = 0;
|
||||
await showPanel(tester, () => closed++);
|
||||
final handle = find.byType(AppSheetHandle);
|
||||
final initial = tester.getSize(find.byType(SelectionBottomBar));
|
||||
await tester.tap(handle);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Action').hitTestable(), findsNothing);
|
||||
await tester.tap(handle);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.getSize(find.byType(SelectionBottomBar)), initial);
|
||||
expect(find.text('Action').hitTestable(), findsOneWidget);
|
||||
|
||||
await tester.drag(handle, const Offset(0, 150));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.timedDrag(
|
||||
handle,
|
||||
const Offset(0, -140),
|
||||
const Duration(seconds: 1),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.getSize(find.byType(SelectionBottomBar)), initial);
|
||||
expect(find.text('Action').hitTestable(), findsOneWidget);
|
||||
expect(closed, 0);
|
||||
});
|
||||
|
||||
testWidgets('$name: close button still cancels immediately', (
|
||||
tester,
|
||||
) async {
|
||||
var closed = 0;
|
||||
await showPanel(tester, () => closed++);
|
||||
await tester.tap(find.byTooltip('Close'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(closed, 1);
|
||||
});
|
||||
|
||||
testWidgets('$name: short drag returns without closing', (tester) async {
|
||||
var closed = 0;
|
||||
await showPanel(tester, () => closed++);
|
||||
@@ -123,6 +231,8 @@ void main() {
|
||||
) async {
|
||||
var closed = 0;
|
||||
await showPanel(tester, () => closed++);
|
||||
await tester.tap(find.byType(AppSheetHandle));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.drag(find.byType(AppSheetHandle), const Offset(0, 150));
|
||||
await tester.pump(const Duration(milliseconds: 30));
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
@@ -138,6 +248,7 @@ void main() {
|
||||
await showPanel(tester, () => closed++, tall: true);
|
||||
final surface = find.byType(SingleChildScrollView);
|
||||
final initial = tester.getTopLeft(surface);
|
||||
final headerPosition = tester.getTopLeft(find.text('1 selected'));
|
||||
final scroll = tester.widget<SingleChildScrollView>(surface).controller!;
|
||||
await tester.timedDrag(
|
||||
surface,
|
||||
@@ -147,6 +258,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(scroll.offset, greaterThan(180));
|
||||
expect(tester.getTopLeft(surface), initial);
|
||||
expect(tester.getTopLeft(find.text('1 selected')), headerPosition);
|
||||
final offset = scroll.offset;
|
||||
await tester.timedDrag(
|
||||
surface,
|
||||
@@ -157,6 +269,66 @@ void main() {
|
||||
expect(scroll.offset, lessThan(offset));
|
||||
expect(tester.getTopLeft(surface), initial);
|
||||
expect(closed, 0);
|
||||
// The fixed header can collapse actions even when they are scrolled.
|
||||
await tester.drag(find.byType(AppSheetHandle), const Offset(0, 200));
|
||||
await tester.pumpAndSettle();
|
||||
expect(scroll.offset, 0);
|
||||
expect(find.text('1 selected'), findsOneWidget);
|
||||
expect(
|
||||
tester.getSize(find.byType(SelectionBottomBar)).height,
|
||||
lessThan(160),
|
||||
);
|
||||
expect(closed, 0);
|
||||
});
|
||||
|
||||
testWidgets('$name: pulling actions at the top also collapses the panel', (
|
||||
tester,
|
||||
) async {
|
||||
var closed = 0;
|
||||
await showPanel(tester, () => closed++);
|
||||
await tester.timedDrag(
|
||||
find.byType(SingleChildScrollView),
|
||||
const Offset(0, 150),
|
||||
const Duration(seconds: 1),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(closed, 0);
|
||||
expect(find.text('Action').hitTestable(), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('$name: large text leaves the compact header usable', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(360, 640);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
var closed = 0;
|
||||
await showPanel(tester, () => closed++, tall: true, textScale: 2);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.tap(find.byType(AppSheetHandle));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('1 selected').hitTestable(), findsOneWidget);
|
||||
expect(find.byTooltip('Close').hitTestable(), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(closed, 0);
|
||||
});
|
||||
|
||||
testWidgets('$name: compact actions are hidden from accessibility', (
|
||||
tester,
|
||||
) async {
|
||||
final semantics = tester.ensureSemantics();
|
||||
try {
|
||||
await showPanel(tester, () {});
|
||||
expect(find.semantics.byLabel('Collapse'), findsOne);
|
||||
await tester.tap(find.byType(AppSheetHandle));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.semantics.byLabel('Expand'), findsOne);
|
||||
expect(find.semantics.byLabel('Action'), findsNothing);
|
||||
expect(find.text('1 selected').hitTestable(), findsOneWidget);
|
||||
} finally {
|
||||
semantics.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user