Skip to content

Commit 6b956dc

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: #391
1 parent 9044a9a commit 6b956dc

File tree

3 files changed

+136
-4
lines changed

3 files changed

+136
-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: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,35 @@ import '../flutter_checks.dart';
1313
import '../model/binding.dart';
1414
import '../model/test_store.dart';
1515

16+
/// Repeatedly drags `view` by `moveStep` until `finder` is invisible.
17+
///
18+
/// Between each drag, advances the clock by `duration`.
19+
///
20+
/// Throws a [StateError] if `finder` is still visible after `maxIteration`
21+
/// drags.
22+
Future<void> dragUntilInvisible(
23+
WidgetTester tester,
24+
FinderBase<Element> finder,
25+
FinderBase<Element> view,
26+
Offset moveStep, {
27+
int maxIteration = 50,
28+
Duration duration = const Duration(milliseconds: 50),
29+
}) {
30+
return TestAsyncUtils.guard<void>(() async {
31+
final iteration = maxIteration;
32+
while (maxIteration > 0 && finder.evaluate().isNotEmpty) {
33+
await tester.drag(view, moveStep);
34+
await tester.pump(duration);
35+
maxIteration -= 1;
36+
}
37+
if (maxIteration <= 0 && finder.evaluate().isNotEmpty) {
38+
throw StateError(
39+
'Finder is still visible after $iteration iterations.'
40+
' Consider increasing the number of iterations.');
41+
}
42+
});
43+
}
44+
1645
void main() {
1746
TestZulipBinding.ensureInitialized();
1847

@@ -51,6 +80,15 @@ void main() {
5180
await tester.pumpAndSettle();
5281
}
5382

83+
List<StreamMessage> generateStreamMessages({
84+
required ZulipStream stream,
85+
required int count,
86+
required List<MessageFlag> flags,
87+
}) {
88+
return List.generate(count, (index) => eg.streamMessage(
89+
stream: stream, topic: '${stream.name} topic $index', flags: flags));
90+
}
91+
5492
/// Set up an inbox view with lots of interesting content.
5593
Future<void> setupVarious(WidgetTester tester) async {
5694
final stream1 = eg.stream(streamId: 1, name: 'stream 1');
@@ -61,12 +99,16 @@ void main() {
6199
await setupPage(tester,
62100
streams: [stream1, stream2],
63101
subscriptions: [sub1, sub2],
64-
users: [eg.selfUser, eg.otherUser, eg.thirdUser],
102+
users: [eg.selfUser, eg.otherUser, eg.thirdUser, eg.fourthUser],
65103
unreadMessages: [
66104
eg.streamMessage(stream: stream1, topic: 'specific topic', flags: []),
105+
...generateStreamMessages(stream: stream1, count: 10, flags: []),
67106
eg.streamMessage(stream: stream2, flags: []),
107+
...generateStreamMessages(stream: stream2, count: 40, flags: []),
68108
eg.dmMessage(from: eg.otherUser, to: [eg.selfUser], flags: []),
69109
eg.dmMessage(from: eg.otherUser, to: [eg.selfUser, eg.thirdUser], flags: []),
110+
eg.dmMessage(from: eg.thirdUser, to: [eg.selfUser], flags: []),
111+
eg.dmMessage(from: eg.fourthUser, to: [eg.selfUser], flags: []),
70112
]);
71113
}
72114

@@ -310,6 +352,29 @@ void main() {
310352
checkAppearsUncollapsed(tester, findSectionContent);
311353
});
312354

355+
testWidgets('collapse all-DMs section after scroll', (tester) async {
356+
await setupVarious(tester);
357+
358+
final listFinder = find.byType(Scrollable);
359+
final dmFinder = find.text(eg.otherUser.fullName).hitTestable();
360+
361+
// Scroll part of [_AllDmsSection] offscreen.
362+
await dragUntilInvisible(
363+
tester, dmFinder, listFinder, const Offset(0, -50));
364+
365+
final headerRow = findAllDmsHeaderRow(tester);
366+
// Check that the header is present (which must therefore
367+
// be as a sticky header).
368+
check(headerRow).isNotNull();
369+
370+
await tapCollapseIcon(tester);
371+
372+
final headerRowAfterTap = findAllDmsHeaderRow(tester);
373+
// Check that the header is still visible even after
374+
// collapsing the section.
375+
check(headerRowAfterTap).isNotNull();
376+
});
377+
313378
// TODO check it remains collapsed even if you scroll far away and back
314379

315380
// TODO check that it's always uncollapsed when it appears after being
@@ -385,6 +450,50 @@ void main() {
385450
checkAppearsUncollapsed(tester, 1, findSectionContent);
386451
});
387452

453+
testWidgets('collapse stream section after scroll', (tester) async {
454+
await setupVarious(tester);
455+
456+
final topicFinder = find.text('stream 1 topic 4' ).hitTestable();
457+
final listFinder = find.byType(Scrollable);
458+
459+
// Scroll part of [_StreamSection] offscreen.
460+
await dragUntilInvisible(
461+
tester, topicFinder, listFinder, const Offset(0, -50));
462+
463+
final headerRow = findStreamHeaderRow(tester, 1);
464+
// Check that the header is present (which must therefore
465+
// be as a sticky header).
466+
check(headerRow).isNotNull();
467+
468+
await tapCollapseIcon(tester, 1);
469+
470+
final headerRowAfterTap = findStreamHeaderRow(tester, 1);
471+
// Check that the header is still visible even after
472+
// collapsing the section.
473+
check(headerRowAfterTap).isNotNull();
474+
});
475+
476+
testWidgets('collapse stream section in the middle of screen', (tester) async {
477+
await setupVarious(tester);
478+
479+
final headerRow = findStreamHeaderRow(tester, 1);
480+
final rectBeforeTap = tester.getRect(find.byWidget(headerRow!));
481+
482+
// Check that the header is present (which must therefore
483+
// be somewhere in the middle of screen)
484+
check(headerRow).isNotNull();
485+
486+
await tapCollapseIcon(tester, 1);
487+
488+
final headerRowAfterTap = findStreamHeaderRow(tester, 1);
489+
// Check that the position of the header before and after
490+
// collapsing is the same.
491+
final rectAfterTap =
492+
tester.getRect(find.byWidget(headerRowAfterTap!));
493+
494+
check(rectAfterTap).equals(rectBeforeTap);
495+
});
496+
388497
// TODO check it remains collapsed even if you scroll far away and back
389498

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

0 commit comments

Comments
 (0)