-
Notifications
You must be signed in to change notification settings - Fork 306
compose: Refactor some logic; implement redesigned error banner #1090
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Neat, thanks! I agree this direction of abstracting and deduplicating this logic looks useful.
Quick comments below since I'll be out for the next couple of workdays (plus the holiday).
lib/widgets/compose_box.dart
Outdated
topicController: controller.topic, | ||
controller: controller.content, | ||
focusNode: controller.contentFocusNode, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A further step that I think could be helpful is if ComposeBox grew an InheritedWidget and a static of
method. Then instead of passing these controller pieces down through each layer, the descendant widgets that need them could say ComposeBox.of(context).controller
and get what they need.
Or maybe the of
method would even just return the controller — that's the meaningful part of the state anyway.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Alternatively, the widgets could continue taking this information as parameters — but as a single parameter that is the whole controller. That seems like it'd also clean this up a lot.
Either way, one thing I'm thinking of in particular is how #924 adds enabled
and sendMessageError
, and has to pass them down through layers in a bunch of places (especially enabled
). So ideally I'd like to see a refactor that makes it so a change like #924 can add a field to the controller and then not have to add references to it at a bunch of widget constructors but still be able to use it at various widgets deeper inside the compose box, like the various buttons.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With any of these approaches, we still need to listen to the possible updates. Our current setup for listener has a fine granularity — _ContentInputState
listens to ComposeContentController
and a FocusNode
, _StreamContentInputState
just listens to ComposeTopicController
…
Continuing this trend, we may have individual listeners for enabled
and sendMessageError
as well, while turning _TopicInput
and probably some others into StatefulWidget
s.
With InheritedWidget
, maybe we can use BuildContext.dependOnInheritedWidgetOfExactType
to simplify that part.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the existing ways we have things listen don't need to change.
The one thing worth avoiding with fine vs. coarse granularity here is that it'd be good to minimize how much needs to rebuild every time the user types another character. So things that don't need to depend on the text controllers should avoid doing so. But if things depend on enabled
or sendMessageError
when they don't strictly need to, that isn't a problem - those change much less frequently.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
InheritedWidget
I almost made a commit like this, but then noticed the branch was getting pretty long. :) I also noticed the InheritedWidget
API prompting me to think about correctly handling changes to the value (the controller), such as by not calling dependOnInheritedWidgetOfExactType
in an initState
method. It seemed like something to consider carefully, but I didn't have a clear idea of when the controller would change (or if/when we'd want it to), so I didn't go ahead with it.
lib/widgets/theme.dart
Outdated
bgContextMenu: Color.lerp(bgContextMenu, other.bgContextMenu, t)!, | ||
bgCounterUnread: Color.lerp(bgCounterUnread, other.bgCounterUnread, t)!, | ||
bgTopBar: Color.lerp(bgTopBar, other.bgTopBar, t)!, | ||
borderBar: Color.lerp(borderBar, other.borderBar, t)!, | ||
btnLabelAttLowIntDanger: Color.lerp(btnLabelAttLowIntDanger, other.btnLabelAttLowIntDanger, t)!, | ||
btnLabelAttMediumDanger: Color.lerp(btnLabelAttMediumDanger, other.btnLabelAttMediumDanger, t)!, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
note to self: same comment as #924 (comment) ; this should be btnLabelAttMediumIntDanger
; it's btn-label-att_medium-int_danger
in the Figma
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for working on this. Looks good overall! Left some comments.
lib/widgets/compose_box.dart
Outdated
final _contentFocusNode = FocusNode(); | ||
class _FixedDestinationComposeBoxState extends State<_FixedDestinationComposeBox> { | ||
FixedDestinationComposeBoxController get controller => _controller; | ||
final FixedDestinationComposeBoxController _controller = FixedDestinationComposeBoxController(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: it should be fine to just use final
without specifying the type here
final _streamComposeBoxControllerKey = GlobalKey<_StreamComposeBoxState>(); | ||
final _fixedDestinationComposeBoxControllerKey = GlobalKey<_FixedDestinationComposeBoxState>(); | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: extra newline
lib/widgets/action_sheet.dart
Outdated
// The compose box doesn't null out its controller; it's either always null | ||
// (e.g. in Combined Feed) or always non-null; it can't have been nulled out | ||
// after the action sheet opened. | ||
assert (composeBoxController != null); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we still need this assertion given the crunchy-shell check that follows this?
lib/widgets/compose_box.dart
Outdated
assert(_controller is FixedDestinationComposeBoxController); | ||
return _FixedDestinationComposeBox(controller: (_controller as FixedDestinationComposeBoxController), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: how about
assert(_controller is FixedDestinationComposeBoxController); | |
return _FixedDestinationComposeBox(controller: (_controller as FixedDestinationComposeBoxController), | |
case TopicNarrow(): | |
_controller as FixedDestinationComposeBoxController; | |
return _FixedDestinationComposeBox(controller: _controller, narrow: narrow); |
for here and similarly for case DmNarrow()
, so that the return
's are just a bit over 70 characters. I'm not sure if the assertions are necessary.
test/widgets/compose_box_test.dart
Outdated
narrow: TopicNarrow(channel.streamId, 'topic'), streams: [channel]); | ||
checkComposeBoxTextFields(tester, controllerKey: key, | ||
final controller = (await prepareComposeBox(tester, | ||
narrow: TopicNarrow(channel.streamId, 'topic'), streams: [channel]))!; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It might be cleaner to make controller
a shared late
variable so that we don't have to wrap the await prepareComposeBox()
call.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ooh nice idea, thanks.
lib/widgets/compose_box.dart
Outdated
if (errorBanner != null) { | ||
body = errorBanner; | ||
body = null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I feel that having to do this makes only calling _ComposeBoxContainer
once less appealing.
Using an early return here when the errorBanner
is present can make it easier to reason about the value of body
and errorBanner
. And we can comfortably pass errorBanner: null
at the end, or body: null
at the beginning, for clarity.
How about:
diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart
index 1ca2e4ae..6514a331 100644
--- a/lib/widgets/compose_box.dart
+++ b/lib/widgets/compose_box.dart
@@ -1343,12 +1343,12 @@ class _ComposeBoxState extends State<ComposeBox> implements ComposeBoxState {
@override
Widget build(BuildContext context) {
final Widget? body;
- Widget? errorBanner;
- errorBanner = _errorBanner(context);
+ final errorBanner = _errorBanner(context);
if (errorBanner != null) {
- body = null;
- } else {
+ return _ComposeBoxContainer(body: null, errorBanner: errorBanner);
+ }
+
// TODO(#720) dismissable message-send error, maybe something like:
// if (controller.sendMessageError.value != null) {
// errorBanner = _ErrorBanner(label:
@@ -1374,8 +1374,7 @@ class _ComposeBoxState extends State<ComposeBox> implements ComposeBoxState {
assert(false);
body = null;
}
- }
- return _ComposeBoxContainer(body: body, errorBanner: errorBanner);
+ return _ComposeBoxContainer(body: body, errorBanner: null);
}
}
For zulip#720, we'd like to add fields to ComposeBoxController and use them in a bunch of widgets deeper in the compose box, like the various buttons, without having to add references to them in those widgets' constructors. With that in mind, pass the whole controller down, and the widgets can access the fields directly from it. As suggested by Greg: zulip#1090 (comment)
84c812e
to
6b36b35
Compare
Thanks for the reviews! Revision pushed. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the revision! This looks good to me. The final commit sequence seems to imply that there is the 6/6 commit passing controller
down. I went through the file and found no other places left to refactor, so we should be all good on this, except for updating the commit summaries.
result = controller.topic.hasValidationErrors.value; | ||
} | ||
result |= controller.content.hasValidationErrors.value; | ||
return result; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I feel that this is an indicator to further abstract away logic around validation errors, probably as a future follow-up.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not yet picturing what that might look like, so I won't add a TODO in this revision, but I'm happy to if you help me understand better (or you can push that TODO yourself, wiki-style, after this is merged).
Oh, oops! Looks like I did 1, 2, 3, 3, 4, 5 😅—will fix. |
6b36b35
to
695624b
Compare
(Done.) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! This all looks good — just nits below. Also pushing a small added commit for one other nit:
ff85cef compose [nfc]: Simplify controller getters to final fields
// This will be null only if the compose box disappeared after the | ||
// message action sheet opened, and before "Quote and reply" was pressed. | ||
// Currently a compose box can't ever disappear, so this is impossible. | ||
var composeBoxController = findMessageListPage().composeBoxController!; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Convenient that we had these comments explaining why the !
had been appropriate in the first place. That particularly helps with the commit
cfe6b3a action_sheet: Fix rare null-check error on quote-and-reply
that points out it's no longer appropriate.
lib/widgets/compose_box.dart
Outdated
_contentController.dispose(); | ||
_contentFocusNode.dispose(); | ||
_topic.dispose(); | ||
_topicFocusNode.dispose(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
compose [nfc]: Make state "has-a" instead of "is-a" ComposeBoxController
It looks like there's a small bug this commit fixes, in addition to its NFC changes: previously we weren't disposing the topic FocusNode.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ooh thanks for the catch; I'll put the bugfix in a separate commit just before this one.
test/widgets/compose_box_test.dart
Outdated
check(controller).isA<StreamComposeBoxController>(); | ||
final topicTextField = tester.widgetList<TextField>( | ||
find.byWidgetPredicate((widget) { | ||
final topicController = (controller as StreamComposeBoxController).topic; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: can pull topicController
's initializer outside the predicate:
check(controller).isA<StreamComposeBoxController>(); | |
final topicTextField = tester.widgetList<TextField>( | |
find.byWidgetPredicate((widget) { | |
final topicController = (controller as StreamComposeBoxController).topic; | |
check(controller).isA<StreamComposeBoxController>(); | |
final topicController = (controller as StreamComposeBoxController).topic; | |
final topicTextField = tester.widgetList<TextField>( | |
find.byWidgetPredicate((widget) { |
lib/widgets/compose_box.dart
Outdated
Widget? topicInput(); | ||
Widget contentInput(); | ||
Widget sendButton(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: as methods, these should be named buildTopicInput
etc
lib/widgets/compose_box.dart
Outdated
child: Column(children: [ | ||
if (topicInput != null) topicInput!, | ||
contentInput, | ||
final topicInput = this.topicInput(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
so e.g.
final topicInput = this.topicInput(); | |
final topicInput = buildTopicInput(); |
lib/widgets/compose_box.dart
Outdated
/// This widget should use a [SafeArea] to pad the left/right device insets. | ||
/// If [body] is null it will also receive a bottom inset to pad; | ||
/// it should pad that too. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about this version?
/// This widget should use a [SafeArea] to pad the left/right device insets. | |
/// If [body] is null it will also receive a bottom inset to pad; | |
/// it should pad that too. | |
/// This widget should use a [SafeArea] to pad the left, right, | |
/// and bottom device insets. | |
/// (A bottom inset may occur if [body] is null.) |
That seems like simpler advice to follow, unless there's some reason I'm missing that makes it hard in some case.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, looks good!
It's a little confusing to see a widget named ComposeBox returning something that's neither a compose box nor a substitute/placeholder for one, but something (SizedBox.shrink) that's meant to show nothing at all. So, pull that logic out to the caller, which nicely puts it near some related logic (the `hasComposeBox` condition for a MediaQuery.removePadding).
Each of these tests will get a new related test, coming up.
In 052f203 it became possible for the compose box to disappear, which would null out MessageListPageState._composeBoxKey.currentState. Anyway, a small and rare bug, but easy to fix.
…posed Thanks Greg for catching this: zulip#1090 (comment)
We've been using ComposeBoxController as an interface, implemented by _StreamComposeBoxState and _FixedDestinationComposeBoxState. This commit puts the relevant implementation in ComposeBoxController itself -- really in subclasses StreamComposeBoxController and FixedDestinationComposeBoxController -- and makes those _FooState classes instantiate it and store the instance in a field. Next, we'll move the field up from those _FooState classes onto ComposeBox's state to make the controller straightforwardly available there (i.e. available without going through GlobalKey logic). That will be helpful for zulip#720 when the controller grows a send-in-progress flag; ComposeBox will be a conveniently central place to build a progress indicator for that state.
Now there's just one place in the code where the controller is stored, instead of in both _StreamComposeBoxState and _FixedDestinationComposeBoxState. Also, those two widgets are simplified to be stateless. One small functional change: if the compose box disappears and then reappears -- because the self-user lost and gained posting permission, or a DM recipient was deactivated then reactivated -- the compose box returns to its state before it disappeared, instead of being re-initialized. This should be rare, but seems fine and helpful when it happens; it's one fewer reason your compose progress might get lost.
This widget is already doing more than layout (sizing and positioning its inputs on the screen): it creates the attachment buttons and styles their touch feedback, and it defeats an unwanted border that its `topicInput` and `contentInput` params might otherwise create. I chose the name "body" to distinguish this from some other elements that will need to share the compose-box surface, for the UI part of issue zulip#720: an error banner and a linear progress indicator. Then also add "body" to the names _StreamComposeBox and _FixedDestinationComposeBox. Since the recent commits that simplified those, they're really just thin, stateless wrappers that configure a _ComposeBoxBody.
As mentioned in a previous commit, these have become thin, stateless wrappers that configure _ComposeBoxBody. Factor them as subclasses, which feels neater and should help keep their implementations from diverging unintentionally.
Now, _ComposeBoxContainer is available as a nice place to put things other than the "body" that (unlike the body) will want to consume the left and right insets. Specifically, for zulip#720, an error banner and an overlaid progress indicator. Next, we'll rename `_ComposeBoxContainer.child` to `body`. We'll also go ahead and add a param for the error banner, redesign the error banner that we currently have and are passing as `child` / `body`, and pass the banner as that new param instead.
This cherry-picks (with some adjustments) UI changes from Zixuan's current revision of PR zulip#924 (for issue zulip#720): compose: Support error banner redesign for replacing the compose box See the Figma: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=4031-15770&node-type=frame&t=vOSEWlXAhBa5EsAv-0 The new design is meant to put the banner at the very top of the compose-box surface, with no margin. So zulip#1089, where the old banner's top margin disappeared, looking buggy, is resolved. The surface (but not content) of the redesigned banner seems like it should extend through any left/right device insets to those edges of the screen. By taking the banner as param separate from `body`, _ComposeBoxContainer lets the banner do that -- without dropping its responsibility for keeping the body (inputs, compose buttons, and send button) out of the insets. The body doesn't need to consume the insets itself and it's already complicated enough without needing its own SafeAreas. It also seems like the banner should pad the bottom inset when it's meant to replace the body, so we do that and make the expectation clear in the `errorBanner` dartdoc. Co-authored-by: Zixuan James Li <[email protected]> Fixes: zulip#1089
For zulip#720, we'd like to add fields to ComposeBoxController and use them in a bunch of widgets deeper in the compose box, like the various buttons, without having to add references to them in those widgets' constructors. With that in mind, pass the whole controller down, and the widgets can access the fields directly from it. As suggested by Greg: zulip#1090 (comment)
A private final field exposed by a public getter is equivalent to a public final field: either means that any library can get, and no library can set.
ff85cef
to
f46187a
Compare
Thanks for the review! Revision pushed. |
Thanks for the revision! Looks good — merging. |
Prompted by reviewing Zixuan's #924, this is meant to make the compose-box code better prepared for that work than it is in
main
; details at #924 (review) and in the commits.Please let me know as soon as anything doesn't look clear. I'm tired (it's almost bedtime 🙂) but I've spent a while on this and I wanted to get it in today if possible. Since this makes a UI change, screenshots coming soon.
Fixes: #1089