Skip to content

Commit 72047ef

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 72047ef

File tree

3 files changed

+110
-7
lines changed

3 files changed

+110
-7
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: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,14 +110,16 @@ final Account selfAccount = account(
110110
apiKey: 'asdfqwer',
111111
);
112112

113-
final User otherUser = user(fullName: 'Other User', email: 'other@example');
113+
final User otherUser = user(fullName: 'Other User', email: 'a@example');
114114
final Account otherAccount = account(
115115
id: 1002,
116116
user: otherUser,
117117
apiKey: 'sdfgwert',
118118
);
119119

120-
final User thirdUser = user(fullName: 'Third User', email: 'third@example');
120+
final User thirdUser = user(fullName: 'Third User', email: 'b@example');
121+
122+
final User fourthUser = user(fullName: 'Fourth User', email: 'cb@example');
121123

122124
////////////////////////////////////////////////////////////////
123125
// Streams and subscriptions.

test/widgets/inbox_test.dart

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ void main() {
5151
await tester.pumpAndSettle();
5252
}
5353

54+
List<StreamMessage> generateStreamMessages(ZulipStream stream, int count) {
55+
return List.generate(count, (index) => eg.streamMessage(
56+
stream: stream, topic: '${stream.name} topic $index', flags: []));
57+
}
58+
5459
/// Set up an inbox view with lots of interesting content.
5560
Future<void> setupVarious(WidgetTester tester) async {
5661
final stream1 = eg.stream(streamId: 1, name: 'stream 1');
@@ -61,12 +66,16 @@ void main() {
6166
await setupPage(tester,
6267
streams: [stream1, stream2],
6368
subscriptions: [sub1, sub2],
64-
users: [eg.selfUser, eg.otherUser, eg.thirdUser],
69+
users: [eg.selfUser, eg.otherUser, eg.thirdUser, eg.fourthUser],
6570
unreadMessages: [
6671
eg.streamMessage(stream: stream1, topic: 'specific topic', flags: []),
72+
...generateStreamMessages(stream1, 10),
6773
eg.streamMessage(stream: stream2, flags: []),
74+
...generateStreamMessages(stream2, 40),
6875
eg.dmMessage(from: eg.otherUser, to: [eg.selfUser], flags: []),
6976
eg.dmMessage(from: eg.otherUser, to: [eg.selfUser, eg.thirdUser], flags: []),
77+
eg.dmMessage(from: eg.thirdUser, to: [eg.selfUser], flags: []),
78+
eg.dmMessage(from: eg.fourthUser, to: [eg.selfUser], flags: []),
7079
]);
7180
}
7281

@@ -252,6 +261,28 @@ void main() {
252261
|| widget.icon == ZulipIcons.arrow_right))));
253262
}
254263

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

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

315370
// TODO check that it's always uncollapsed when it appears after being
@@ -325,7 +380,7 @@ void main() {
325380
check(headerRow).isNotNull();
326381
final icon = findHeaderCollapseIcon(tester, headerRow!);
327382
await tester.tap(find.byWidget(icon));
328-
await tester.pump();
383+
await tester.pumpAndSettle();
329384
}
330385

331386
/// Check that the section appears uncollapsed.
@@ -385,6 +440,29 @@ void main() {
385440
checkAppearsUncollapsed(tester, 1, findSectionContent);
386441
});
387442

443+
testWidgets('collapse stream section after scroll', (tester) async {
444+
await setupVarious(tester);
445+
446+
final topicFinder = find.text('stream 1 topic 4' ).hitTestable();
447+
final listFinder = find.byType(Scrollable);
448+
449+
// Scroll the [StickyHeaderListView] enough so that
450+
// the [_StreamSection] shows a sticky header
451+
await dragUntilInvisible(
452+
tester, topicFinder, listFinder, const Offset(0, -100));
453+
454+
Widget? headerRow = findStreamHeaderRow(tester, 1);
455+
// Check that the header is present (which in this case
456+
// is a sticky one as we've scrolled enough)
457+
check(headerRow).isNotNull();
458+
459+
await tapCollapseIcon(tester, 1);
460+
// Check that the header is still visible even after
461+
// collapsing the section
462+
headerRow = findStreamHeaderRow(tester, 1);
463+
check(headerRow).isNotNull();
464+
});
465+
388466
// TODO check it remains collapsed even if you scroll far away and back
389467

390468
// TODO check that it's always uncollapsed when it appears after being
@@ -393,6 +471,8 @@ void main() {
393471
// reappear because a new unread arrived, but with #346 it could also
394472
// reappear because you unmuted a conversation.)
395473
});
474+
475+
396476
});
397477
});
398478
}

0 commit comments

Comments
 (0)