Skip to content

Commit 6ffba75

Browse files
committed
action_sheet: Add and use PageRoot
Without this, the upcoming resolve/unresolve feature would have a bug where certain error dialogs would fail to appear. (Specifically the ones that explain to the user that a topic has changed between opening the action sheet and pressing the resolve/unresolve button.)
1 parent ccdf1c5 commit 6ffba75

File tree

4 files changed

+103
-60
lines changed

4 files changed

+103
-60
lines changed

lib/widgets/action_sheet.dart

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import 'emoji_reaction.dart';
2424
import 'icons.dart';
2525
import 'inset_shadow.dart';
2626
import 'message_list.dart';
27+
import 'page.dart';
2728
import 'store.dart';
2829
import 'text.dart';
2930
import 'theme.dart';
@@ -163,11 +164,15 @@ class ActionSheetCancelButton extends StatelessWidget {
163164
}
164165

165166
/// Show a sheet of actions you can take on a topic.
167+
///
168+
/// Needs a [PageRoot] ancestor.
166169
void showTopicActionSheet(BuildContext context, {
167170
required int channelId,
168171
required TopicName topic,
169172
}) {
170-
final store = PerAccountStoreWidget.of(context);
173+
final pageRootContext = PageRoot.contextOf(context);
174+
175+
final store = PerAccountStoreWidget.of(pageRootContext);
171176
final subscription = store.subscriptions[channelId];
172177

173178
final optionButtons = <ActionSheetMenuItemButton>[];
@@ -237,7 +242,7 @@ void showTopicActionSheet(BuildContext context, {
237242
currentVisibilityPolicy: visibilityPolicy,
238243
newVisibilityPolicy: to,
239244
narrow: TopicNarrow(channelId, topic),
240-
pageContext: context);
245+
pageContext: pageRootContext);
241246
}));
242247

243248
if (optionButtons.isEmpty) {
@@ -250,7 +255,7 @@ void showTopicActionSheet(BuildContext context, {
250255
return;
251256
}
252257

253-
_showActionSheet(context, optionButtons: optionButtons);
258+
_showActionSheet(pageRootContext, optionButtons: optionButtons);
254259
}
255260

256261
class UserTopicUpdateButton extends ActionSheetMenuItemButton {
@@ -374,35 +379,36 @@ class UserTopicUpdateButton extends ActionSheetMenuItemButton {
374379

375380
/// Show a sheet of actions you can take on a message in the message list.
376381
///
377-
/// Must have a [MessageListPage] ancestor.
382+
/// Must have a [MessageListPage] ancestor and a [PageRoot] ancestor.
378383
void showMessageActionSheet({required BuildContext context, required Message message}) {
379-
final store = PerAccountStoreWidget.of(context);
384+
final pageRootContext = PageRoot.contextOf(context);
385+
final store = PerAccountStoreWidget.of(pageRootContext);
380386

381387
// The UI that's conditioned on this won't live-update during this appearance
382388
// of the action sheet (we avoid calling composeBoxControllerOf in a build
383389
// method; see its doc).
384390
// So we rely on the fact that isComposeBoxOffered for any given message list
385391
// will be constant through the page's life.
386-
final messageListPage = MessageListPage.ancestorOf(context);
392+
final messageListPage = MessageListPage.ancestorOf(pageRootContext);
387393
final isComposeBoxOffered = messageListPage.composeBoxController != null;
388394

389395
final isMessageRead = message.flags.contains(MessageFlag.read);
390396
final markAsUnreadSupported = store.connection.zulipFeatureLevel! >= 155; // TODO(server-6)
391397
final showMarkAsUnreadButton = markAsUnreadSupported && isMessageRead;
392398

393399
final optionButtons = [
394-
ReactionButtons(message: message, pageContext: context),
395-
StarButton(message: message, pageContext: context),
400+
ReactionButtons(message: message, pageContext: pageRootContext),
401+
StarButton(message: message, pageContext: pageRootContext),
396402
if (isComposeBoxOffered)
397-
QuoteAndReplyButton(message: message, pageContext: context),
403+
QuoteAndReplyButton(message: message, pageContext: pageRootContext),
398404
if (showMarkAsUnreadButton)
399-
MarkAsUnreadButton(message: message, pageContext: context),
400-
CopyMessageTextButton(message: message, pageContext: context),
401-
CopyMessageLinkButton(message: message, pageContext: context),
402-
ShareButton(message: message, pageContext: context),
405+
MarkAsUnreadButton(message: message, pageContext: pageRootContext),
406+
CopyMessageTextButton(message: message, pageContext: pageRootContext),
407+
CopyMessageLinkButton(message: message, pageContext: pageRootContext),
408+
ShareButton(message: message, pageContext: pageRootContext),
403409
];
404410

405-
_showActionSheet(context, optionButtons: optionButtons);
411+
_showActionSheet(pageRootContext, optionButtons: optionButtons);
406412
}
407413

408414
abstract class MessageActionSheetMenuItemButton extends ActionSheetMenuItemButton {

lib/widgets/message_list.dart

Lines changed: 45 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -277,48 +277,51 @@ class _MessageListPageState extends State<MessageListPage> implements MessageLis
277277
narrow: ChannelNarrow(streamId)))));
278278
}
279279

280-
return Scaffold(
281-
appBar: ZulipAppBar(
282-
buildTitle: (willCenterTitle) =>
283-
MessageListAppBarTitle(narrow: narrow, willCenterTitle: willCenterTitle),
284-
actions: actions,
285-
backgroundColor: appBarBackgroundColor,
286-
shape: removeAppBarBottomBorder
287-
? const Border()
288-
: null, // i.e., inherit
289-
),
290-
// TODO question for Vlad: for a stream view, should we set the Scaffold's
291-
// [backgroundColor] based on stream color, as in this frame:
292-
// https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=132%3A9684&mode=dev
293-
// That's not obviously preferred over the default background that
294-
// we matched to the Figma in 21dbae120. See another frame, which uses that:
295-
// https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=147%3A9088&mode=dev
296-
body: Builder(
297-
builder: (BuildContext context) => Column(
298-
// Children are expected to take the full horizontal space
299-
// and handle the horizontal device insets.
300-
// The bottom inset should be handled by the last child only.
301-
children: [
302-
MediaQuery.removePadding(
303-
// Scaffold knows about the app bar, and so has run this
304-
// BuildContext, which is under `body`, through
305-
// MediaQuery.removePadding with `removeTop: true`.
306-
context: context,
307-
308-
// The compose box, when present, pads the bottom inset.
309-
// TODO(#311) If we have a bottom nav, it will pad the bottom
310-
// inset, and this should always be true.
311-
removeBottom: ComposeBox.hasComposeBox(narrow),
312-
313-
child: Expanded(
314-
child: MessageList(
315-
key: _messageListKey,
316-
narrow: narrow,
317-
onNarrowChanged: _narrowChanged,
318-
))),
319-
if (ComposeBox.hasComposeBox(narrow))
320-
ComposeBox(key: _composeBoxKey, narrow: narrow)
321-
])));
280+
// The Builder enables MessageListPage.ancestorOf
281+
// on the context provided to the message action sheet by PageRoot.
282+
return Builder(builder: (context) => PageRoot(context: context,
283+
child: Scaffold(
284+
appBar: ZulipAppBar(
285+
buildTitle: (willCenterTitle) =>
286+
MessageListAppBarTitle(narrow: narrow, willCenterTitle: willCenterTitle),
287+
actions: actions,
288+
backgroundColor: appBarBackgroundColor,
289+
shape: removeAppBarBottomBorder
290+
? const Border()
291+
: null, // i.e., inherit
292+
),
293+
// TODO question for Vlad: for a stream view, should we set the Scaffold's
294+
// [backgroundColor] based on stream color, as in this frame:
295+
// https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=132%3A9684&mode=dev
296+
// That's not obviously preferred over the default background that
297+
// we matched to the Figma in 21dbae120. See another frame, which uses that:
298+
// https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=147%3A9088&mode=dev
299+
body: Builder(
300+
builder: (BuildContext context) => Column(
301+
// Children are expected to take the full horizontal space
302+
// and handle the horizontal device insets.
303+
// The bottom inset should be handled by the last child only.
304+
children: [
305+
MediaQuery.removePadding(
306+
// Scaffold knows about the app bar, and so has run this
307+
// BuildContext, which is under `body`, through
308+
// MediaQuery.removePadding with `removeTop: true`.
309+
context: context,
310+
311+
// The compose box, when present, pads the bottom inset.
312+
// TODO(#311) If we have a bottom nav, it will pad the bottom
313+
// inset, and this should always be true.
314+
removeBottom: ComposeBox.hasComposeBox(narrow),
315+
316+
child: Expanded(
317+
child: MessageList(
318+
key: _messageListKey,
319+
narrow: narrow,
320+
onNarrowChanged: _narrowChanged,
321+
))),
322+
if (ComposeBox.hasComposeBox(narrow))
323+
ComposeBox(key: _composeBoxKey, narrow: narrow)
324+
])))));
322325
}
323326
}
324327

lib/widgets/page.dart

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,33 @@ import 'package:flutter/material.dart';
33

44
import 'store.dart';
55

6+
/// An [InheritedWidget] for near the root of a page's widget subtree,
7+
/// providing the given [context].
8+
///
9+
/// Useful when needing a context that persists through the page's lifespan,
10+
/// e.g. for a show-action-sheet function
11+
/// whose buttons use a context to close the sheet
12+
/// or show an error dialog / snackbar asynchronously.
13+
///
14+
/// (In this scenario, it would be buggy to use the context of the element
15+
/// that was long-pressed if Zulip events can cause the element to unmount,
16+
/// e.g., a delete-message event.)
17+
class PageRoot extends InheritedWidget {
18+
const PageRoot({super.key, required this.context, required super.child});
19+
20+
final BuildContext context;
21+
22+
@override
23+
bool updateShouldNotify(covariant PageRoot oldWidget) =>
24+
!identical(oldWidget.context, context);
25+
26+
static BuildContext contextOf(BuildContext context) {
27+
final widget = context.dependOnInheritedWidgetOfExactType<PageRoot>();
28+
assert(widget != null, 'No PageRoot ancestor');
29+
return widget!.context;
30+
}
31+
}
32+
633
/// A page route that always builds the same widget.
734
///
835
/// This is useful for making the route more transparent for a test to inspect.
@@ -42,7 +69,10 @@ mixin AccountPageRouteMixin<T extends Object?> on PageRoute<T> {
4269
accountId: accountId,
4370
placeholder: loadingPlaceholderPage ?? const LoadingPlaceholderPage(),
4471
routeToRemoveOnLogout: this,
45-
child: super.buildPage(context, animation, secondaryAnimation));
72+
// The Builder enables PerAccountStoreWidget.of
73+
// on the context provided by PageRoot (e.g. to action-sheet buttons).
74+
child: Builder(builder: (context) => PageRoot(context: context,
75+
child: super.buildPage(context, animation, secondaryAnimation))));
4676
}
4777
}
4878

test/widgets/action_sheet_test.dart

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@ import 'package:zulip/model/narrow.dart';
2121
import 'package:zulip/model/store.dart';
2222
import 'package:zulip/model/typing_status.dart';
2323
import 'package:zulip/widgets/action_sheet.dart';
24+
import 'package:zulip/widgets/app.dart';
2425
import 'package:zulip/widgets/app_bar.dart';
2526
import 'package:zulip/widgets/compose_box.dart';
2627
import 'package:zulip/widgets/content.dart';
2728
import 'package:zulip/widgets/emoji.dart';
28-
import 'package:zulip/widgets/home.dart';
2929
import 'package:zulip/widgets/icons.dart';
3030
import 'package:zulip/widgets/inbox.dart';
3131
import 'package:zulip/widgets/message_list.dart';
@@ -160,9 +160,13 @@ void main() {
160160
]);
161161
}
162162

163-
await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
164-
child: const HomePage()));
163+
// ZulipApp instead of TestZulipApp so that we can push a route to the nav,
164+
// exercising HomePage.buildRoute which (via AccountPageRouteMixin)
165+
// makes a PageRoot, which the action sheet needs.
166+
await tester.pumpWidget(ZulipApp());
165167
await tester.pump();
168+
169+
// If this fails, adjust setup so the inbox page is visible.
166170
check(find.byType(InboxPageBody)).findsOne();
167171

168172
await tester.longPress(find.text(topic));

0 commit comments

Comments
 (0)