Skip to content

Commit c3bce0e

Browse files
committed
inbox: Fix collapsing a section from its sticky header
Before this fix, when scrolled down through the inbox page, collapsing a section (either the all-DMs section or a stream section) through its sticky header would work but cause an abnormal behavior, pushing the current section header and some of the following sections off the screen by the amount of space the current section occupied. After this fix, collapsing a section through a sticky header would work as expected, without pushing the header and the following sections off the screen. Fixes: zulip#391
1 parent 9044a9a commit c3bce0e

File tree

3 files changed

+125
-4
lines changed

3 files changed

+125
-4
lines changed

lib/widgets/inbox.dart

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -209,11 +209,19 @@ abstract class _HeaderItem extends StatelessWidget {
209209
final int count;
210210
final bool hasMention;
211211

212+
/// Context to access the [_StreamSection] or [_AllDmsSection].
213+
///
214+
/// Used to ensure the [_StreamSection] or [_AllDmsSection] that encloses the
215+
/// current [_HeaderItem] is visible after being collapsed through this
216+
/// [_HeaderItem].
217+
final BuildContext sectionContext;
218+
212219
const _HeaderItem({
213220
required this.collapsed,
214221
required this.pageState,
215222
required this.count,
216223
required this.hasMention,
224+
required this.sectionContext,
217225
});
218226

219227
String get title;
@@ -223,7 +231,14 @@ abstract class _HeaderItem extends StatelessWidget {
223231
Color get uncollapsedBackgroundColor;
224232
Color? get unreadCountBadgeBackgroundColor;
225233

226-
void Function() get onCollapseButtonTap;
234+
Future<void> Function() get onCollapseButtonTap => () async {
235+
if (!collapsed) {
236+
await Scrollable.ensureVisible(
237+
sectionContext,
238+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
239+
);
240+
}
241+
};
227242
void Function() get onRowTap;
228243

229244
@override
@@ -271,6 +286,7 @@ class _AllDmsHeaderItem extends _HeaderItem {
271286
required super.pageState,
272287
required super.count,
273288
required super.hasMention,
289+
required super.sectionContext,
274290
});
275291

276292
@override get title => 'Direct messages'; // TODO(i18n)
@@ -280,7 +296,8 @@ class _AllDmsHeaderItem extends _HeaderItem {
280296
@override get uncollapsedBackgroundColor => const Color(0xFFF3F0E7);
281297
@override get unreadCountBadgeBackgroundColor => null;
282298

283-
@override get onCollapseButtonTap => () {
299+
@override get onCollapseButtonTap => () async {
300+
await super.onCollapseButtonTap();
284301
pageState.allDmsCollapsed = !collapsed;
285302
};
286303
@override get onRowTap => onCollapseButtonTap; // TODO open all-DMs narrow?
@@ -304,6 +321,7 @@ class _AllDmsSection extends StatelessWidget {
304321
hasMention: data.hasMention,
305322
collapsed: collapsed,
306323
pageState: pageState,
324+
sectionContext: context,
307325
);
308326
return StickyHeaderItem(
309327
header: header,
@@ -386,6 +404,7 @@ class _StreamHeaderItem extends _HeaderItem {
386404
required super.pageState,
387405
required super.count,
388406
required super.hasMention,
407+
required super.sectionContext,
389408
});
390409

391410
@override get title => subscription.name;
@@ -397,7 +416,8 @@ class _StreamHeaderItem extends _HeaderItem {
397416
@override get unreadCountBadgeBackgroundColor =>
398417
subscription.colorSwatch().unreadCountBadgeBackground;
399418

400-
@override get onCollapseButtonTap => () {
419+
@override get onCollapseButtonTap => () async {
420+
await super.onCollapseButtonTap();
401421
if (collapsed) {
402422
pageState.uncollapseStream(subscription.streamId);
403423
} else {
@@ -427,6 +447,7 @@ class _StreamSection extends StatelessWidget {
427447
hasMention: data.hasMention,
428448
collapsed: collapsed,
429449
pageState: pageState,
450+
sectionContext: context,
430451
);
431452
return StickyHeaderItem(
432453
header: header,

test/example_data.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ final Account otherAccount = account(
119119

120120
final User thirdUser = user(fullName: 'Third User', email: 'third@example');
121121

122+
final User fourthUser = user(fullName: 'Fourth User', email: 'fourth@example');
123+
122124
////////////////////////////////////////////////////////////////
123125
// Streams and subscriptions.
124126
//

test/widgets/inbox_test.dart

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,15 @@ void main() {
5151
await tester.pumpAndSettle();
5252
}
5353

54+
List<StreamMessage> generateStreamMessages({
55+
required ZulipStream stream,
56+
required int count,
57+
required List<MessageFlag> flags,
58+
}) {
59+
return List.generate(count, (index) => eg.streamMessage(
60+
stream: stream, topic: '${stream.name} topic $index', flags: flags));
61+
}
62+
5463
/// Set up an inbox view with lots of interesting content.
5564
Future<void> setupVarious(WidgetTester tester) async {
5665
final stream1 = eg.stream(streamId: 1, name: 'stream 1');
@@ -61,12 +70,16 @@ void main() {
6170
await setupPage(tester,
6271
streams: [stream1, stream2],
6372
subscriptions: [sub1, sub2],
64-
users: [eg.selfUser, eg.otherUser, eg.thirdUser],
73+
users: [eg.selfUser, eg.otherUser, eg.thirdUser, eg.fourthUser],
6574
unreadMessages: [
6675
eg.streamMessage(stream: stream1, topic: 'specific topic', flags: []),
76+
...generateStreamMessages(stream: stream1, count: 5, flags: []),
6777
eg.streamMessage(stream: stream2, flags: []),
78+
...generateStreamMessages(stream: stream2, count: 40, flags: []),
6879
eg.dmMessage(from: eg.otherUser, to: [eg.selfUser], flags: []),
6980
eg.dmMessage(from: eg.otherUser, to: [eg.selfUser, eg.thirdUser], flags: []),
81+
eg.dmMessage(from: eg.thirdUser, to: [eg.selfUser], flags: []),
82+
eg.dmMessage(from: eg.fourthUser, to: [eg.selfUser], flags: []),
7083
]);
7184
}
7285

@@ -252,6 +265,28 @@ void main() {
252265
|| widget.icon == ZulipIcons.arrow_right))));
253266
}
254267

268+
Future<void> dragUntilInvisible(
269+
WidgetTester tester,
270+
FinderBase<Element> finder,
271+
FinderBase<Element> view,
272+
Offset moveStep, {
273+
int maxIteration = 50,
274+
Duration duration = const Duration(milliseconds: 50),
275+
}) {
276+
return TestAsyncUtils.guard<void>(() async {
277+
final iteration = maxIteration;
278+
while (maxIteration > 0 && finder.evaluate().isNotEmpty) {
279+
await tester.drag(view, moveStep);
280+
await tester.pump(duration);
281+
maxIteration -= 1;
282+
}
283+
if (maxIteration <= 0 && finder.evaluate().isNotEmpty) {
284+
throw StateError(
285+
'Finder is still visible after $iteration iterations. Consider increasing the number of iterations.');
286+
}
287+
});
288+
}
289+
255290
group('all-DMs section', () {
256291
Future<void> tapCollapseIcon(WidgetTester tester) async {
257292
final headerRow = findAllDmsHeaderRow(tester);
@@ -310,6 +345,30 @@ void main() {
310345
checkAppearsUncollapsed(tester, findSectionContent);
311346
});
312347

348+
testWidgets('collapse all-DMs section after scroll', (tester) async {
349+
await setupVarious(tester);
350+
351+
final listFinder = find.byType(Scrollable);
352+
final findSectionContent =
353+
find.text(eg.otherUser.fullName).hitTestable();
354+
355+
// Scroll the [StickyHeaderListView] enough so that
356+
// the [_AllDmsSection] shows a sticky header
357+
await dragUntilInvisible(
358+
tester, findSectionContent, listFinder, const Offset(0, -50));
359+
360+
Widget? headerRow = findAllDmsHeaderRow(tester);
361+
// Check that the header is present (which in this case
362+
// is a sticky one as we've scrolled enough)
363+
check(headerRow).isNotNull();
364+
365+
await tapCollapseIcon(tester);
366+
// Check that the header is still visible even after
367+
// collapsing the section
368+
headerRow = findAllDmsHeaderRow(tester);
369+
check(headerRow).isNotNull();
370+
});
371+
313372
// TODO check it remains collapsed even if you scroll far away and back
314373

315374
// TODO check that it's always uncollapsed when it appears after being
@@ -385,6 +444,45 @@ void main() {
385444
checkAppearsUncollapsed(tester, 1, findSectionContent);
386445
});
387446

447+
testWidgets('collapse stream section after scroll', (tester) async {
448+
await setupVarious(tester);
449+
450+
final topicFinder = find.text('stream 1 topic 2' ).hitTestable();
451+
final listFinder = find.byType(Scrollable);
452+
453+
// Scroll the [StickyHeaderListView] enough so that
454+
// the [_StreamSection] shows a sticky header
455+
await dragUntilInvisible(
456+
tester, topicFinder, listFinder, const Offset(0, -100));
457+
458+
Widget? headerRow = findStreamHeaderRow(tester, 1);
459+
// Check that the header is present (which in this case
460+
// is a sticky one as we've scrolled enough)
461+
check(headerRow).isNotNull();
462+
463+
await tapCollapseIcon(tester, 1);
464+
// Check that the header is still visible even after
465+
// collapsing the section
466+
headerRow = findStreamHeaderRow(tester, 1);
467+
check(headerRow).isNotNull();
468+
});
469+
470+
testWidgets('collapse stream section in the middle of screen', (tester) async {
471+
await setupVarious(tester);
472+
473+
Widget? headerRow = findStreamHeaderRow(tester, 1);
474+
final rectBeforeTap = tester.getRect(find.byWidget(headerRow!));
475+
476+
check(headerRow).isNotNull();
477+
478+
await tapCollapseIcon(tester, 1);
479+
480+
headerRow = findStreamHeaderRow(tester, 1);
481+
final rectAfterTap = tester.getRect(find.byWidget(headerRow!));
482+
483+
check(rectAfterTap).equals(rectBeforeTap);
484+
});
485+
388486
// TODO check it remains collapsed even if you scroll far away and back
389487

390488
// TODO check that it's always uncollapsed when it appears after being

0 commit comments

Comments
 (0)