diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index c271df6bfe..c1e2c0e1f2 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -14,6 +14,7 @@ import '../model/narrow.dart'; import 'actions.dart'; import 'clipboard.dart'; import 'color.dart'; +import 'compose_box.dart'; import 'dialog.dart'; import 'icons.dart'; import 'inset_shadow.dart'; @@ -325,22 +326,22 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton { @override void onPressed() async { final zulipLocalizations = ZulipLocalizations.of(pageContext); - // 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!; - final topicController = composeBoxController.topicController; + var composeBoxController = findMessageListPage().composeBoxController; + // 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. + composeBoxController!; if ( - topicController != null - && topicController.textNormalized == kNoTopicTopic + composeBoxController is StreamComposeBoxController + && composeBoxController.topic.textNormalized == kNoTopicTopic && message is StreamMessage ) { - topicController.value = TextEditingValue(text: message.topic); + composeBoxController.topic.value = TextEditingValue(text: message.topic); } // This inserts a "[Quoting…]" placeholder into the content input, // giving the user a form of progress feedback. - final tag = composeBoxController.contentController + final tag = composeBoxController.content .registerQuoteAndReplyStart(PerAccountStoreWidget.of(pageContext), message: message, ); @@ -353,11 +354,11 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton { if (!pageContext.mounted) return; - // This will be null only if the compose box disappeared during the - // quotation request. Currently a compose box can't ever disappear, - // so this is impossible. - composeBoxController = findMessageListPage().composeBoxController!; - composeBoxController.contentController + composeBoxController = findMessageListPage().composeBoxController; + // 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 + // during the raw-content request. + composeBoxController!.content .registerQuoteAndReplyEnd(PerAccountStoreWidget.of(pageContext), tag, message: message, rawContent: rawContent, diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index a22e4f7c78..114e392bc7 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -277,14 +277,12 @@ class _ContentInput extends StatefulWidget { required this.narrow, required this.destination, required this.controller, - required this.focusNode, required this.hintText, }); final Narrow narrow; final SendableNarrow destination; - final ComposeContentController controller; - final FocusNode focusNode; + final ComposeBoxController controller; final String hintText; @override @@ -295,8 +293,8 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve @override void initState() { super.initState(); - widget.controller.addListener(_contentChanged); - widget.focusNode.addListener(_focusChanged); + widget.controller.content.addListener(_contentChanged); + widget.controller.contentFocusNode.addListener(_focusChanged); WidgetsBinding.instance.addObserver(this); } @@ -304,32 +302,30 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve void didUpdateWidget(covariant _ContentInput oldWidget) { super.didUpdateWidget(oldWidget); if (widget.controller != oldWidget.controller) { - oldWidget.controller.removeListener(_contentChanged); - widget.controller.addListener(_contentChanged); - } - if (widget.focusNode != oldWidget.focusNode) { - oldWidget.focusNode.removeListener(_focusChanged); - widget.focusNode.addListener(_focusChanged); + oldWidget.controller.content.removeListener(_contentChanged); + widget.controller.content.addListener(_contentChanged); + oldWidget.controller.contentFocusNode.removeListener(_focusChanged); + widget.controller.contentFocusNode.addListener(_focusChanged); } } @override void dispose() { - widget.controller.removeListener(_contentChanged); - widget.focusNode.removeListener(_focusChanged); + widget.controller.content.removeListener(_contentChanged); + widget.controller.contentFocusNode.removeListener(_focusChanged); WidgetsBinding.instance.removeObserver(this); super.dispose(); } void _contentChanged() { final store = PerAccountStoreWidget.of(context); - (widget.controller.text.isEmpty) + (widget.controller.content.text.isEmpty) ? store.typingNotifier.stoppedComposing() : store.typingNotifier.keystroke(widget.destination); } void _focusChanged() { - if (widget.focusNode.hasFocus) { + if (widget.controller.contentFocusNode.hasFocus) { // Content input getting focus doesn't necessarily mean that // the user started typing, so do nothing. return; @@ -397,8 +393,8 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve return ComposeAutocomplete( narrow: widget.narrow, - controller: widget.controller, - focusNode: widget.focusNode, + controller: widget.controller.content, + focusNode: widget.controller.contentFocusNode, fieldViewBuilder: (context) => ConstrainedBox( constraints: BoxConstraints(maxHeight: maxHeight(context)), // This [ClipRect] replaces the [TextField] clipping we disable below. @@ -407,8 +403,8 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve top: _verticalPadding, bottom: _verticalPadding, color: designVariables.composeBoxBg, child: TextField( - controller: widget.controller, - focusNode: widget.focusNode, + controller: widget.controller.content, + focusNode: widget.controller.contentFocusNode, // Let the content show through the `contentPadding` so that // our [InsetShadowBox] can fade it smoothly there. clipBehavior: Clip.none, @@ -442,17 +438,10 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve /// The content input for _StreamComposeBox. class _StreamContentInput extends StatefulWidget { - const _StreamContentInput({ - required this.narrow, - required this.controller, - required this.topicController, - required this.focusNode, - }); + const _StreamContentInput({required this.narrow, required this.controller}); final ChannelNarrow narrow; - final ComposeContentController controller; - final ComposeTopicController topicController; - final FocusNode focusNode; + final StreamComposeBoxController controller; @override State<_StreamContentInput> createState() => _StreamContentInputState(); @@ -463,29 +452,29 @@ class _StreamContentInputState extends State<_StreamContentInput> { void _topicChanged() { setState(() { - _topicTextNormalized = widget.topicController.textNormalized; + _topicTextNormalized = widget.controller.topic.textNormalized; }); } @override void initState() { super.initState(); - _topicTextNormalized = widget.topicController.textNormalized; - widget.topicController.addListener(_topicChanged); + _topicTextNormalized = widget.controller.topic.textNormalized; + widget.controller.topic.addListener(_topicChanged); } @override void didUpdateWidget(covariant _StreamContentInput oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.topicController != oldWidget.topicController) { - oldWidget.topicController.removeListener(_topicChanged); - widget.topicController.addListener(_topicChanged); + if (widget.controller.topic != oldWidget.controller.topic) { + oldWidget.controller.topic.removeListener(_topicChanged); + widget.controller.topic.addListener(_topicChanged); } } @override void dispose() { - widget.topicController.removeListener(_topicChanged); + widget.controller.topic.removeListener(_topicChanged); super.dispose(); } @@ -499,22 +488,15 @@ class _StreamContentInputState extends State<_StreamContentInput> { narrow: widget.narrow, destination: TopicNarrow(widget.narrow.streamId, _topicTextNormalized), controller: widget.controller, - focusNode: widget.focusNode, hintText: zulipLocalizations.composeBoxChannelContentHint(streamName, _topicTextNormalized)); } } class _TopicInput extends StatelessWidget { - const _TopicInput({ - required this.streamId, - required this.controller, - required this.focusNode, - required this.contentFocusNode}); + const _TopicInput({required this.streamId, required this.controller}); final int streamId; - final ComposeTopicController controller; - final FocusNode focusNode; - final FocusNode contentFocusNode; + final StreamComposeBoxController controller; @override Widget build(BuildContext context) { @@ -528,17 +510,17 @@ class _TopicInput extends StatelessWidget { return TopicAutocomplete( streamId: streamId, - controller: controller, - focusNode: focusNode, - contentFocusNode: contentFocusNode, + controller: controller.topic, + focusNode: controller.topicFocusNode, + contentFocusNode: controller.contentFocusNode, fieldViewBuilder: (context) => Container( padding: const EdgeInsets.only(top: 10, bottom: 9), decoration: BoxDecoration(border: Border(bottom: BorderSide( width: 1, color: designVariables.foreground.withFadedAlpha(0.2)))), child: TextField( - controller: controller, - focusNode: focusNode, + controller: controller.topic, + focusNode: controller.topicFocusNode, textInputAction: TextInputAction.next, style: topicTextStyle, decoration: InputDecoration( @@ -552,12 +534,10 @@ class _FixedDestinationContentInput extends StatelessWidget { const _FixedDestinationContentInput({ required this.narrow, required this.controller, - required this.focusNode, }); final SendableNarrow narrow; - final ComposeContentController controller; - final FocusNode focusNode; + final FixedDestinationComposeBoxController controller; String _hintText(BuildContext context) { final zulipLocalizations = ZulipLocalizations.of(context); @@ -588,7 +568,6 @@ class _FixedDestinationContentInput extends StatelessWidget { narrow: narrow, destination: narrow, controller: controller, - focusNode: focusNode, hintText: _hintText(context)); } } @@ -679,10 +658,9 @@ Future _uploadFiles({ } abstract class _AttachUploadsButton extends StatelessWidget { - const _AttachUploadsButton({required this.contentController, required this.contentFocusNode}); + const _AttachUploadsButton({required this.controller}); - final ComposeContentController contentController; - final FocusNode contentFocusNode; + final ComposeBoxController controller; IconData get icon; String tooltip(ZulipLocalizations zulipLocalizations); @@ -710,8 +688,8 @@ abstract class _AttachUploadsButton extends StatelessWidget { await _uploadFiles( context: context, - contentController: contentController, - contentFocusNode: contentFocusNode, + contentController: controller.content, + contentFocusNode: controller.contentFocusNode, files: files); } @@ -783,7 +761,7 @@ Future> _getFilePickerFiles(BuildContext context, FileType type) } class _AttachFileButton extends _AttachUploadsButton { - const _AttachFileButton({required super.contentController, required super.contentFocusNode}); + const _AttachFileButton({required super.controller}); @override IconData get icon => ZulipIcons.attach_file; @@ -799,7 +777,7 @@ class _AttachFileButton extends _AttachUploadsButton { } class _AttachMediaButton extends _AttachUploadsButton { - const _AttachMediaButton({required super.contentController, required super.contentFocusNode}); + const _AttachMediaButton({required super.controller}); @override IconData get icon => ZulipIcons.image; @@ -816,7 +794,7 @@ class _AttachMediaButton extends _AttachUploadsButton { } class _AttachFromCameraButton extends _AttachUploadsButton { - const _AttachFromCameraButton({required super.contentController, required super.contentFocusNode}); + const _AttachFromCameraButton({required super.controller}); @override IconData get icon => ZulipIcons.camera; @@ -888,14 +866,9 @@ class _AttachFromCameraButton extends _AttachUploadsButton { } class _SendButton extends StatefulWidget { - const _SendButton({ - required this.topicController, - required this.contentController, - required this.getDestination, - }); + const _SendButton({required this.controller, required this.getDestination}); - final ComposeTopicController? topicController; - final ComposeContentController contentController; + final ComposeBoxController controller; final MessageDestination Function() getDestination; @override @@ -912,43 +885,62 @@ class _SendButtonState extends State<_SendButton> { @override void initState() { super.initState(); - widget.topicController?.hasValidationErrors.addListener(_hasErrorsChanged); - widget.contentController.hasValidationErrors.addListener(_hasErrorsChanged); + final controller = widget.controller; + if (controller is StreamComposeBoxController) { + controller.topic.hasValidationErrors.addListener(_hasErrorsChanged); + } + controller.content.hasValidationErrors.addListener(_hasErrorsChanged); } @override void didUpdateWidget(covariant _SendButton oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.topicController != oldWidget.topicController) { - oldWidget.topicController?.hasValidationErrors.removeListener(_hasErrorsChanged); - widget.topicController?.hasValidationErrors.addListener(_hasErrorsChanged); + + final controller = widget.controller; + final oldController = oldWidget.controller; + if (controller == oldController) return; + + if (oldController is StreamComposeBoxController) { + oldController.topic.hasValidationErrors.removeListener(_hasErrorsChanged); } - if (widget.contentController != oldWidget.contentController) { - oldWidget.contentController.hasValidationErrors.removeListener(_hasErrorsChanged); - widget.contentController.hasValidationErrors.addListener(_hasErrorsChanged); + if (controller is StreamComposeBoxController) { + controller.topic.hasValidationErrors.addListener(_hasErrorsChanged); } + oldController.content.hasValidationErrors.removeListener(_hasErrorsChanged); + controller.content.hasValidationErrors.addListener(_hasErrorsChanged); } @override void dispose() { - widget.topicController?.hasValidationErrors.removeListener(_hasErrorsChanged); - widget.contentController.hasValidationErrors.removeListener(_hasErrorsChanged); + final controller = widget.controller; + if (controller is StreamComposeBoxController) { + controller.topic.hasValidationErrors.removeListener(_hasErrorsChanged); + } + controller.content.hasValidationErrors.removeListener(_hasErrorsChanged); super.dispose(); } bool get _hasValidationErrors { - return (widget.topicController?.hasValidationErrors.value ?? false) - || widget.contentController.hasValidationErrors.value; + bool result = false; + final controller = widget.controller; + if (controller is StreamComposeBoxController) { + result = controller.topic.hasValidationErrors.value; + } + result |= controller.content.hasValidationErrors.value; + return result; } void _send() async { + final controller = widget.controller; + if (_hasValidationErrors) { final zulipLocalizations = ZulipLocalizations.of(context); List validationErrorMessages = [ - for (final error in widget.topicController?.validationErrors - ?? const []) + for (final error in (controller is StreamComposeBoxController + ? controller.topic.validationErrors + : const [])) error.message(zulipLocalizations), - for (final error in widget.contentController.validationErrors) + for (final error in controller.content.validationErrors) error.message(zulipLocalizations), ]; showErrorDialog( @@ -959,9 +951,9 @@ class _SendButtonState extends State<_SendButton> { } final store = PerAccountStoreWidget.of(context); - final content = widget.contentController.textNormalized; + final content = controller.content.textNormalized; - widget.contentController.clear(); + controller.content.clear(); // The following `stoppedComposing` call is currently redundant, // because clearing input sends a "typing stopped" notice. // It will be necessary once we resolve #720. @@ -1010,40 +1002,74 @@ class _SendButtonState extends State<_SendButton> { } class _ComposeBoxContainer extends StatelessWidget { - const _ComposeBoxContainer({required this.child}); + const _ComposeBoxContainer({ + required this.body, + this.errorBanner, + }) : assert(body != null || errorBanner != null); + + /// The text inputs, compose-button row, and send button. + /// + /// This widget does not need a [SafeArea] to consume any device insets. + /// + /// Can be null, but only if [errorBanner] is non-null. + final Widget? body; - final Widget child; + /// An error bar that goes at the top. + /// + /// This may be present on its own or with a [body]. + /// If [body] is null this must be present. + /// + /// This widget should use a [SafeArea] to pad the left, right, + /// and bottom device insets. + /// (A bottom inset may occur if [body] is null.) + final Widget? errorBanner; + + Widget _paddedBody() { + assert(body != null); + return SafeArea(minimum: const EdgeInsets.symmetric(horizontal: 8), + child: body!); + } @override Widget build(BuildContext context) { final designVariables = DesignVariables.of(context); + final List children = switch ((errorBanner, body)) { + (Widget(), Widget()) => [ + // _paddedBody() already pads the bottom inset, + // so make sure the error banner doesn't double-pad it. + MediaQuery.removePadding(context: context, removeBottom: true, + child: errorBanner!), + _paddedBody(), + ], + (Widget(), null) => [errorBanner!], + (null, Widget()) => [_paddedBody()], + (null, null) => throw UnimplementedError(), // not allowed, see dartdoc + }; + // TODO(design): Maybe put a max width on the compose box, like we do on // the message list itself return Container(width: double.infinity, decoration: BoxDecoration( border: Border(top: BorderSide(color: designVariables.borderBar))), + // TODO(#720) try a Stack for the overlaid linear progress indicator child: Material( color: designVariables.composeBoxBg, - child: SafeArea(minimum: const EdgeInsets.symmetric(horizontal: 8), - child: child))); + child: Column( + children: children))); } } -class _ComposeBoxLayout extends StatelessWidget { - const _ComposeBoxLayout({ - required this.topicInput, - required this.contentInput, - required this.sendButton, - required this.contentController, - required this.contentFocusNode, - }); +/// The text inputs, compose-button row, and send button for the compose box. +abstract class _ComposeBoxBody extends StatelessWidget { + /// The narrow on view in the message list. + Narrow get narrow; + + ComposeBoxController get controller; - final Widget? topicInput; - final Widget contentInput; - final Widget sendButton; - final ComposeContentController contentController; - final FocusNode contentFocusNode; + Widget? buildTopicInput(); + Widget buildContentInput(); + Widget buildSendButton(); @override Widget build(BuildContext context) { @@ -1070,102 +1096,112 @@ class _ComposeBoxLayout extends StatelessWidget { borderRadius: BorderRadius.all(Radius.circular(4))))); final composeButtons = [ - _AttachFileButton(contentController: contentController, contentFocusNode: contentFocusNode), - _AttachMediaButton(contentController: contentController, contentFocusNode: contentFocusNode), - _AttachFromCameraButton(contentController: contentController, contentFocusNode: contentFocusNode), + _AttachFileButton(controller: controller), + _AttachMediaButton(controller: controller), + _AttachFromCameraButton(controller: controller), ]; - return _ComposeBoxContainer( - child: Column(children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Theme( - data: inputThemeData, - child: Column(children: [ - if (topicInput != null) topicInput!, - contentInput, + final topicInput = buildTopicInput(); + return Column(children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Theme( + data: inputThemeData, + child: Column(children: [ + if (topicInput != null) topicInput, + buildContentInput(), + ]))), + SizedBox( + height: _composeButtonSize, + child: IconButtonTheme( + data: iconButtonThemeData, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row(children: composeButtons), + buildSendButton(), ]))), - SizedBox( - height: _composeButtonSize, - child: IconButtonTheme( - data: iconButtonThemeData, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row(children: composeButtons), - sendButton, - ]))), - ])); + ]); } } -abstract class ComposeBoxController extends State { - ComposeTopicController? get topicController; - ComposeContentController get contentController; - FocusNode get contentFocusNode; -} - /// A compose box for use in a channel narrow. /// /// This offers a text input for the topic to send to, /// in addition to a text input for the message content. -class _StreamComposeBox extends StatefulWidget { - const _StreamComposeBox({super.key, required this.narrow}); +class _StreamComposeBoxBody extends _ComposeBoxBody { + _StreamComposeBoxBody({required this.narrow, required this.controller}); - /// The narrow on view in the message list. + @override final ChannelNarrow narrow; @override - State<_StreamComposeBox> createState() => _StreamComposeBoxState(); + final StreamComposeBoxController controller; + + @override Widget buildTopicInput() => _TopicInput( + streamId: narrow.streamId, + controller: controller, + ); + + @override Widget buildContentInput() => _StreamContentInput( + narrow: narrow, + controller: controller, + ); + + @override Widget buildSendButton() => _SendButton( + controller: controller, + getDestination: () => StreamDestination( + narrow.streamId, controller.topic.textNormalized), + ); } -class _StreamComposeBoxState extends State<_StreamComposeBox> implements ComposeBoxController<_StreamComposeBox> { - @override ComposeTopicController get topicController => _topicController; - final _topicController = ComposeTopicController(); +class _FixedDestinationComposeBoxBody extends _ComposeBoxBody { + _FixedDestinationComposeBoxBody({required this.narrow, required this.controller}); + + @override + final SendableNarrow narrow; + + @override + final FixedDestinationComposeBoxController controller; + + @override Widget? buildTopicInput() => null; - @override ComposeContentController get contentController => _contentController; - final _contentController = ComposeContentController(); + @override Widget buildContentInput() => _FixedDestinationContentInput( + narrow: narrow, + controller: controller, + ); - @override FocusNode get contentFocusNode => _contentFocusNode; - final _contentFocusNode = FocusNode(); + @override Widget buildSendButton() => _SendButton( + controller: controller, + getDestination: () => narrow.destination, + ); +} - FocusNode get topicFocusNode => _topicFocusNode; - final _topicFocusNode = FocusNode(); +sealed class ComposeBoxController { + final content = ComposeContentController(); + final contentFocusNode = FocusNode(); - @override + @mustCallSuper void dispose() { - _topicController.dispose(); - _contentController.dispose(); - _contentFocusNode.dispose(); - super.dispose(); + content.dispose(); + contentFocusNode.dispose(); } +} + +class StreamComposeBoxController extends ComposeBoxController { + final topic = ComposeTopicController(); + final topicFocusNode = FocusNode(); @override - Widget build(BuildContext context) { - return _ComposeBoxLayout( - contentController: _contentController, - contentFocusNode: _contentFocusNode, - topicInput: _TopicInput( - streamId: widget.narrow.streamId, - controller: _topicController, - focusNode: topicFocusNode, - contentFocusNode: _contentFocusNode, - ), - contentInput: _StreamContentInput( - narrow: widget.narrow, - topicController: _topicController, - controller: _contentController, - focusNode: _contentFocusNode, - ), - sendButton: _SendButton( - topicController: _topicController, - contentController: _contentController, - getDestination: () => StreamDestination( - widget.narrow.streamId, _topicController.textNormalized), - )); + void dispose() { + topic.dispose(); + topicFocusNode.dispose(); + super.dispose(); } } +class FixedDestinationComposeBoxController extends ComposeBoxController {} + class _ErrorBanner extends StatelessWidget { const _ErrorBanner({required this.label}); @@ -1174,87 +1210,92 @@ class _ErrorBanner extends StatelessWidget { @override Widget build(BuildContext context) { final designVariables = DesignVariables.of(context); - return Container( - padding: const EdgeInsets.all(8), + final labelTextStyle = TextStyle( + fontSize: 17, + height: 22 / 17, + color: designVariables.btnLabelAttMediumIntDanger, + ).merge(weightVariableTextStyle(context, wght: 600)); + + return DecoratedBox( decoration: BoxDecoration( - color: designVariables.errorBannerBackground, - border: Border.all(color: designVariables.errorBannerBorder), - borderRadius: BorderRadius.circular(5)), - child: Text(label, - style: TextStyle(fontSize: 18, color: designVariables.errorBannerLabel), - ), - ); + color: designVariables.bannerBgIntDanger), + child: SafeArea( + minimum: const EdgeInsetsDirectional.only(start: 8) + // (SafeArea.minimum doesn't take an EdgeInsetsDirectional) + .resolve(Directionality.of(context)), + child: Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB(8, 9, 0, 9), + child: Text(style: labelTextStyle, + label))), + const SizedBox(width: 8), + // TODO(#720) "x" button goes here. + // 24px square with 8px touchable padding in all directions? + ]))); } } -class _FixedDestinationComposeBox extends StatefulWidget { - const _FixedDestinationComposeBox({super.key, required this.narrow}); - - final SendableNarrow narrow; - - @override - State<_FixedDestinationComposeBox> createState() => _FixedDestinationComposeBoxState(); -} - -class _FixedDestinationComposeBoxState extends State<_FixedDestinationComposeBox> implements ComposeBoxController<_FixedDestinationComposeBox> { - @override ComposeTopicController? get topicController => null; +class ComposeBox extends StatefulWidget { + ComposeBox({super.key, required this.narrow}) + : assert(ComposeBox.hasComposeBox(narrow)); - @override ComposeContentController get contentController => _contentController; - final _contentController = ComposeContentController(); + final Narrow narrow; - @override FocusNode get contentFocusNode => _contentFocusNode; - final _contentFocusNode = FocusNode(); + static bool hasComposeBox(Narrow narrow) { + switch (narrow) { + case ChannelNarrow(): + case TopicNarrow(): + case DmNarrow(): + return true; - @override - void dispose() { - _contentController.dispose(); - _contentFocusNode.dispose(); - super.dispose(); + case CombinedFeedNarrow(): + case MentionsNarrow(): + case StarredMessagesNarrow(): + return false; + } } @override - Widget build(BuildContext context) { - return _ComposeBoxLayout( - contentController: _contentController, - contentFocusNode: _contentFocusNode, - topicInput: null, - contentInput: _FixedDestinationContentInput( - narrow: widget.narrow, - controller: _contentController, - focusNode: _contentFocusNode, - ), - sendButton: _SendButton( - topicController: null, - contentController: _contentController, - getDestination: () => widget.narrow.destination, - )); - } + State createState() => _ComposeBoxState(); } -class ComposeBox extends StatelessWidget { - const ComposeBox({super.key, this.controllerKey, required this.narrow}); +/// The interface for the state of a [ComposeBox]. +abstract class ComposeBoxState extends State { + ComposeBoxController get controller; +} - final GlobalKey? controllerKey; - final Narrow narrow; +class _ComposeBoxState extends State implements ComposeBoxState { + @override ComposeBoxController get controller => _controller; + late final ComposeBoxController _controller; - static bool hasComposeBox(Narrow narrow) { - switch (narrow) { + @override + void initState() { + super.initState(); + switch (widget.narrow) { case ChannelNarrow(): + _controller = StreamComposeBoxController(); case TopicNarrow(): case DmNarrow(): - return true; - + _controller = FixedDestinationComposeBoxController(); case CombinedFeedNarrow(): case MentionsNarrow(): case StarredMessagesNarrow(): - return false; + assert(false); } } + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + Widget? _errorBanner(BuildContext context) { final store = PerAccountStoreWidget.of(context); final selfUser = store.users[store.selfUserId]!; - switch (narrow) { + switch (widget.narrow) { case ChannelNarrow(:final streamId): case TopicNarrow(:final streamId): final channel = store.streams[streamId]; @@ -1280,23 +1321,30 @@ class ComposeBox extends StatelessWidget { @override Widget build(BuildContext context) { + final Widget? body; + final errorBanner = _errorBanner(context); if (errorBanner != null) { - return _ComposeBoxContainer(child: errorBanner); + return _ComposeBoxContainer(body: null, errorBanner: errorBanner); } - final narrow = this.narrow; - switch (narrow) { - case ChannelNarrow(): - return _StreamComposeBox(key: controllerKey, narrow: narrow); - case TopicNarrow(): - return _FixedDestinationComposeBox(key: controllerKey, narrow: narrow); - case DmNarrow(): - return _FixedDestinationComposeBox(key: controllerKey, narrow: narrow); - case CombinedFeedNarrow(): - case MentionsNarrow(): - case StarredMessagesNarrow(): - return const SizedBox.shrink(); + final narrow = widget.narrow; + switch (_controller) { + case StreamComposeBoxController(): { + narrow as ChannelNarrow; + body = _StreamComposeBoxBody(controller: _controller, narrow: narrow); + } + case FixedDestinationComposeBoxController(): { + narrow as SendableNarrow; + body = _FixedDestinationComposeBoxBody(controller: _controller, narrow: narrow); + } } + + // TODO(#720) dismissable message-send error, maybe something like: + // if (controller.sendMessageError.value != null) { + // errorBanner = _ErrorBanner(label: + // ZulipLocalizations.of(context).errorSendMessageTimeout); + // } + return _ComposeBoxContainer(body: body, errorBanner: null); } } diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index b8fcf3adbe..f9e62f01ce 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -213,9 +213,9 @@ class _MessageListPageState extends State implements MessageLis late Narrow narrow; @override - ComposeBoxController? get composeBoxController => _composeBoxKey.currentState; + ComposeBoxController? get composeBoxController => _composeBoxKey.currentState?.controller; - final GlobalKey _composeBoxKey = GlobalKey(); + final GlobalKey _composeBoxKey = GlobalKey(); @override void initState() { @@ -301,7 +301,8 @@ class _MessageListPageState extends State implements MessageLis child: Expanded( child: MessageList(narrow: narrow, onNarrowChanged: _narrowChanged))), - ComposeBox(controllerKey: _composeBoxKey, narrow: narrow), + if (ComposeBox.hasComposeBox(narrow)) + ComposeBox(key: _composeBoxKey, narrow: narrow) ])))); } } diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index be0ae32ae8..3ba5221245 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -114,10 +114,13 @@ class DesignVariables extends ThemeExtension { DesignVariables.light() : this._( background: const Color(0xffffffff), + bannerBgIntDanger: const Color(0xfff2e4e4), bgContextMenu: const Color(0xfff2f2f2), bgCounterUnread: const Color(0xff666699).withValues(alpha: 0.15), bgTopBar: const Color(0xfff5f5f5), borderBar: Colors.black.withValues(alpha: 0.2), + btnLabelAttLowIntDanger: const Color(0xffc0070a), + btnLabelAttMediumIntDanger: const Color(0xffac0508), composeBoxBg: const Color(0xffffffff), contextMenuCancelText: const Color(0xff222222), contextMenuItemBg: const Color(0xff6159e1), @@ -135,9 +138,6 @@ class DesignVariables extends ThemeExtension { atMentionMarker: const HSLColor.fromAHSL(0.5, 0, 0, 0.2).toColor(), contextMenuCancelBg: const Color(0xff797986), dmHeaderBg: const HSLColor.fromAHSL(1, 46, 0.35, 0.93).toColor(), - errorBannerBackground: const HSLColor.fromAHSL(1, 4, 0.33, 0.90).toColor(), - errorBannerBorder: const HSLColor.fromAHSL(0.4, 3, 0.57, 0.33).toColor(), - errorBannerLabel: const HSLColor.fromAHSL(1, 4, 0.58, 0.33).toColor(), groupDmConversationIcon: Colors.black.withValues(alpha: 0.5), groupDmConversationIconBg: const Color(0x33808080), loginOrDivider: const Color(0xffdedede), @@ -154,10 +154,13 @@ class DesignVariables extends ThemeExtension { DesignVariables.dark() : this._( background: const Color(0xff000000), + bannerBgIntDanger: const Color(0xff461616), bgContextMenu: const Color(0xff262626), bgCounterUnread: const Color(0xff666699).withValues(alpha: 0.37), bgTopBar: const Color(0xff242424), borderBar: Colors.black.withValues(alpha: 0.5), + btnLabelAttLowIntDanger: const Color(0xffff8b7c), + btnLabelAttMediumIntDanger: const Color(0xffff8b7c), composeBoxBg: const Color(0xff0f0f0f), contextMenuCancelText: const Color(0xffffffff).withValues(alpha: 0.75), contextMenuItemBg: const Color(0xff7977fe), @@ -176,9 +179,6 @@ class DesignVariables extends ThemeExtension { // TODO(design-dark) need proper dark-theme color (this is ad hoc) atMentionMarker: const HSLColor.fromAHSL(0.4, 0, 0, 1).toColor(), dmHeaderBg: const HSLColor.fromAHSL(1, 46, 0.15, 0.2).toColor(), - errorBannerBackground: const HSLColor.fromAHSL(1, 0, 0.61, 0.19).toColor(), - errorBannerBorder: const HSLColor.fromAHSL(0.4, 3, 0.73, 0.74).toColor(), - errorBannerLabel: const HSLColor.fromAHSL(1, 2, 0.73, 0.80).toColor(), // TODO(design-dark) need proper dark-theme color (this is ad hoc) groupDmConversationIcon: Colors.white.withValues(alpha: 0.5), // TODO(design-dark) need proper dark-theme color (this is ad hoc) @@ -201,10 +201,13 @@ class DesignVariables extends ThemeExtension { DesignVariables._({ required this.background, + required this.bannerBgIntDanger, required this.bgContextMenu, required this.bgCounterUnread, required this.bgTopBar, required this.borderBar, + required this.btnLabelAttLowIntDanger, + required this.btnLabelAttMediumIntDanger, required this.composeBoxBg, required this.contextMenuCancelText, required this.contextMenuItemBg, @@ -222,9 +225,6 @@ class DesignVariables extends ThemeExtension { required this.atMentionMarker, required this.contextMenuCancelBg, required this.dmHeaderBg, - required this.errorBannerBackground, - required this.errorBannerBorder, - required this.errorBannerLabel, required this.groupDmConversationIcon, required this.groupDmConversationIconBg, required this.loginOrDivider, @@ -249,10 +249,13 @@ class DesignVariables extends ThemeExtension { } final Color background; + final Color bannerBgIntDanger; final Color bgContextMenu; final Color bgCounterUnread; final Color bgTopBar; final Color borderBar; + final Color btnLabelAttLowIntDanger; + final Color btnLabelAttMediumIntDanger; final Color composeBoxBg; final Color contextMenuCancelText; final Color contextMenuItemBg; @@ -274,9 +277,6 @@ class DesignVariables extends ThemeExtension { final Color atMentionMarker; final Color contextMenuCancelBg; // In Figma, but unnamed. final Color dmHeaderBg; - final Color errorBannerBackground; - final Color errorBannerBorder; - final Color errorBannerLabel; final Color groupDmConversationIcon; final Color groupDmConversationIconBg; final Color loginOrDivider; // TODO(design-dark) need proper dark-theme color (this is ad hoc) @@ -292,10 +292,13 @@ class DesignVariables extends ThemeExtension { @override DesignVariables copyWith({ Color? background, + Color? bannerBgIntDanger, Color? bgContextMenu, Color? bgCounterUnread, Color? bgTopBar, Color? borderBar, + Color? btnLabelAttLowIntDanger, + Color? btnLabelAttMediumIntDanger, Color? composeBoxBg, Color? contextMenuCancelText, Color? contextMenuItemBg, @@ -313,9 +316,6 @@ class DesignVariables extends ThemeExtension { Color? atMentionMarker, Color? contextMenuCancelBg, Color? dmHeaderBg, - Color? errorBannerBackground, - Color? errorBannerBorder, - Color? errorBannerLabel, Color? groupDmConversationIcon, Color? groupDmConversationIconBg, Color? loginOrDivider, @@ -330,10 +330,13 @@ class DesignVariables extends ThemeExtension { }) { return DesignVariables._( background: background ?? this.background, + bannerBgIntDanger: bannerBgIntDanger ?? this.bannerBgIntDanger, bgContextMenu: bgContextMenu ?? this.bgContextMenu, bgCounterUnread: bgCounterUnread ?? this.bgCounterUnread, bgTopBar: bgTopBar ?? this.bgTopBar, borderBar: borderBar ?? this.borderBar, + btnLabelAttLowIntDanger: btnLabelAttLowIntDanger ?? this.btnLabelAttLowIntDanger, + btnLabelAttMediumIntDanger: btnLabelAttMediumIntDanger ?? this.btnLabelAttMediumIntDanger, composeBoxBg: composeBoxBg ?? this.composeBoxBg, contextMenuCancelText: contextMenuCancelText ?? this.contextMenuCancelText, contextMenuItemBg: contextMenuItemBg ?? this.contextMenuItemBg, @@ -351,9 +354,6 @@ class DesignVariables extends ThemeExtension { atMentionMarker: atMentionMarker ?? this.atMentionMarker, contextMenuCancelBg: contextMenuCancelBg ?? this.contextMenuCancelBg, dmHeaderBg: dmHeaderBg ?? this.dmHeaderBg, - errorBannerBackground: errorBannerBackground ?? this.errorBannerBackground, - errorBannerBorder: errorBannerBorder ?? this.errorBannerBorder, - errorBannerLabel: errorBannerLabel ?? this.errorBannerLabel, groupDmConversationIcon: groupDmConversationIcon ?? this.groupDmConversationIcon, groupDmConversationIconBg: groupDmConversationIconBg ?? this.groupDmConversationIconBg, loginOrDivider: loginOrDivider ?? this.loginOrDivider, @@ -375,10 +375,13 @@ class DesignVariables extends ThemeExtension { } return DesignVariables._( background: Color.lerp(background, other.background, t)!, + bannerBgIntDanger: Color.lerp(bannerBgIntDanger, other.bannerBgIntDanger, t)!, 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)!, + btnLabelAttMediumIntDanger: Color.lerp(btnLabelAttMediumIntDanger, other.btnLabelAttMediumIntDanger, t)!, composeBoxBg: Color.lerp(composeBoxBg, other.composeBoxBg, t)!, contextMenuCancelText: Color.lerp(contextMenuCancelText, other.contextMenuCancelText, t)!, contextMenuItemBg: Color.lerp(contextMenuItemBg, other.contextMenuItemBg, t)!, @@ -396,9 +399,6 @@ class DesignVariables extends ThemeExtension { atMentionMarker: Color.lerp(atMentionMarker, other.atMentionMarker, t)!, contextMenuCancelBg: Color.lerp(contextMenuCancelBg, other.contextMenuCancelBg, t)!, dmHeaderBg: Color.lerp(dmHeaderBg, other.dmHeaderBg, t)!, - errorBannerBackground: Color.lerp(errorBannerBackground, other.errorBannerBackground, t)!, - errorBannerBorder: Color.lerp(errorBannerBorder, other.errorBannerBorder, t)!, - errorBannerLabel: Color.lerp(errorBannerLabel, other.errorBannerLabel, t)!, groupDmConversationIcon: Color.lerp(groupDmConversationIcon, other.groupDmConversationIcon, t)!, groupDmConversationIconBg: Color.lerp(groupDmConversationIconBg, other.groupDmConversationIconBg, t)!, loginOrDivider: Color.lerp(loginOrDivider, other.loginOrDivider, t)!, diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 3dade81b48..b47a49dba9 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -43,10 +43,16 @@ Future setupToMessageActionSheet(WidgetTester tester, { required Narrow narrow, }) async { addTearDown(testBinding.reset); + assert(narrow.containsMessage(message)); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); store = await testBinding.globalStore.perAccount(eg.selfAccount.id); - await store.addUsers([eg.selfUser, eg.user(userId: message.senderId)]); + await store.addUsers([ + eg.selfUser, + eg.user(userId: message.senderId), + if (narrow is DmNarrow) + ...narrow.otherRecipientIds.map((id) => eg.user(userId: id)), + ]); if (message is StreamMessage) { final stream = eg.stream(streamId: message.streamId); await store.addStream(stream); @@ -224,8 +230,8 @@ void main() { group('QuoteAndReplyButton', () { ComposeBoxController? findComposeBoxController(WidgetTester tester) { - return tester.widget(find.byType(ComposeBox)) - .controllerKey?.currentState; + return tester.stateList(find.byType(ComposeBox)) + .singleOrNull?.controller; } Widget? findQuoteAndReplyButton(WidgetTester tester) { @@ -277,14 +283,14 @@ void main() { final message = eg.streamMessage(); await setupToMessageActionSheet(tester, message: message, narrow: ChannelNarrow(message.streamId)); - final composeBoxController = findComposeBoxController(tester)!; - final contentController = composeBoxController.contentController; + final composeBoxController = findComposeBoxController(tester) as StreamComposeBoxController; + final contentController = composeBoxController.content; // Ensure channel-topics are loaded before testing quote & reply behavior connection.prepare(body: jsonEncode(GetStreamTopicsResult(topics: [eg.getStreamTopicsEntry()]).toJson())); - final topicController = composeBoxController.topicController; - topicController?.value = const TextEditingValue(text: kNoTopicTopic); + final topicController = composeBoxController.topic; + topicController.value = const TextEditingValue(text: kNoTopicTopic); final valueBefore = contentController.value; prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); @@ -296,39 +302,80 @@ void main() { valueBefore: valueBefore, message: message, rawContent: 'Hello world'); }); - testWidgets('in topic narrow', (tester) async { - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + group('in topic narrow', () { + testWidgets('smoke', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - final composeBoxController = findComposeBoxController(tester)!; - final contentController = composeBoxController.contentController; + final composeBoxController = findComposeBoxController(tester)!; + final contentController = composeBoxController.content; + + final valueBefore = contentController.value; + prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); + await tapQuoteAndReplyButton(tester); + checkLoadingState(store, contentController, valueBefore: valueBefore, message: message); + await tester.pump(Duration.zero); // message is fetched; compose box updates + check(composeBoxController.contentFocusNode.hasFocus).isTrue(); + checkSuccessState(store, contentController, + valueBefore: valueBefore, message: message, rawContent: 'Hello world'); + }); - final valueBefore = contentController.value; - prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); - await tapQuoteAndReplyButton(tester); - checkLoadingState(store, contentController, valueBefore: valueBefore, message: message); - await tester.pump(Duration.zero); // message is fetched; compose box updates - check(composeBoxController.contentFocusNode.hasFocus).isTrue(); - checkSuccessState(store, contentController, - valueBefore: valueBefore, message: message, rawContent: 'Hello world'); - }); + testWidgets('no error if user lost posting permission after action sheet opened', (tester) async { + final stream = eg.stream(); + final message = eg.streamMessage(stream: stream); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - testWidgets('in DM narrow', (tester) async { - final message = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]); - await setupToMessageActionSheet(tester, - message: message, narrow: DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: eg.selfUser.userId, + role: UserRole.guest)); + await store.handleEvent(eg.channelUpdateEvent(stream, + property: ChannelPropertyName.channelPostPolicy, + value: ChannelPostPolicy.administrators)); + await tester.pump(); - final composeBoxController = findComposeBoxController(tester)!; - final contentController = composeBoxController.contentController; + await tapQuoteAndReplyButton(tester); + // no error + }); + }); - final valueBefore = contentController.value; - prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); - await tapQuoteAndReplyButton(tester); - checkLoadingState(store, contentController, valueBefore: valueBefore, message: message); - await tester.pump(Duration.zero); // message is fetched; compose box updates - check(composeBoxController.contentFocusNode.hasFocus).isTrue(); - checkSuccessState(store, contentController, - valueBefore: valueBefore, message: message, rawContent: 'Hello world'); + group('in DM narrow', () { + testWidgets('smoke', (tester) async { + final message = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]); + await setupToMessageActionSheet(tester, + message: message, narrow: DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + + final composeBoxController = findComposeBoxController(tester)!; + final contentController = composeBoxController.content; + + final valueBefore = contentController.value; + prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); + await tapQuoteAndReplyButton(tester); + checkLoadingState(store, contentController, valueBefore: valueBefore, message: message); + await tester.pump(Duration.zero); // message is fetched; compose box updates + check(composeBoxController.contentFocusNode.hasFocus).isTrue(); + checkSuccessState(store, contentController, + valueBefore: valueBefore, message: message, rawContent: 'Hello world'); + }); + + testWidgets('no error if recipient was deactivated while raw-content request in progress', (tester) async { + final message = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]); + await setupToMessageActionSheet(tester, + message: message, + narrow: DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + + prepareRawContentResponseSuccess( + message: message, + rawContent: 'Hello world', + delay: const Duration(seconds: 5), + ); + await tapQuoteAndReplyButton(tester); + await tester.pump(const Duration(seconds: 1)); // message not yet fetched + + await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: eg.otherUser.userId, + isActive: false)); + await tester.pump(); + // no error + await tester.pump(const Duration(seconds: 4)); + }); }); testWidgets('request has an error', (tester) async { @@ -336,7 +383,7 @@ void main() { await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); final composeBoxController = findComposeBoxController(tester)!; - final contentController = composeBoxController.contentController; + final contentController = composeBoxController.content; final valueBefore = contentController.value = TextEditingValue.empty; prepareRawContentResponseError(); diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index a19356ef40..11f3afdec1 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -38,11 +38,12 @@ void main() { late PerAccountStore store; late FakeApiConnection connection; + late ComposeBoxController? controller; final contentInputFinder = find.byWidgetPredicate( (widget) => widget is TextField && widget.controller is ComposeContentController); - Future> prepareComposeBox(WidgetTester tester, { + Future prepareComposeBox(WidgetTester tester, { required Narrow narrow, User? selfUser, int? realmWaitingPeriodThreshold, @@ -65,18 +66,17 @@ void main() { await store.addStreams(streams); connection = store.connection as FakeApiConnection; - final controllerKey = GlobalKey(); await tester.pumpWidget(TestZulipApp(accountId: selfAccount.id, child: Column( // This positions the compose box at the bottom of the screen, // simulating the layout of the message list page. children: [ const Expanded(child: SizedBox.expand()), - ComposeBox(controllerKey: controllerKey, narrow: narrow), + ComposeBox(narrow: narrow), ]))); await tester.pumpAndSettle(); - return controllerKey; + controller = tester.state(find.byType(ComposeBox)).controller; } Future enterTopic(WidgetTester tester, { @@ -198,42 +198,39 @@ void main() { group('ComposeBox textCapitalization', () { void checkComposeBoxTextFields(WidgetTester tester, { - required GlobalKey controllerKey, required bool expectTopicTextField, }) { - final composeBoxController = controllerKey.currentState!; - - final topicTextField = tester.widgetList(find.byWidgetPredicate( - (widget) => widget is TextField - && widget.controller == composeBoxController.topicController)).singleOrNull; if (expectTopicTextField) { + final topicController = (controller as StreamComposeBoxController).topic; + final topicTextField = tester.widgetList(find.byWidgetPredicate( + (widget) => widget is TextField && widget.controller == topicController + )).singleOrNull; check(topicTextField).isNotNull() .textCapitalization.equals(TextCapitalization.none); } else { - check(topicTextField).isNull(); + check(controller).isA(); + check(find.byType(TextField)).findsOne(); // just content input, no topic } final contentTextField = tester.widget(find.byWidgetPredicate( (widget) => widget is TextField - && widget.controller == composeBoxController.contentController)); + && widget.controller == controller!.content)); check(contentTextField) .textCapitalization.equals(TextCapitalization.sentences); } testWidgets('_StreamComposeBox', (tester) async { final channel = eg.stream(); - final key = await prepareComposeBox(tester, + await prepareComposeBox(tester, narrow: ChannelNarrow(channel.streamId), streams: [channel]); - checkComposeBoxTextFields(tester, controllerKey: key, - expectTopicTextField: true); + checkComposeBoxTextFields(tester, expectTopicTextField: true); }); testWidgets('_FixedDestinationComposeBox', (tester) async { final channel = eg.stream(); - final key = await prepareComposeBox(tester, + await prepareComposeBox(tester, narrow: TopicNarrow(channel.streamId, 'topic'), streams: [channel]); - checkComposeBoxTextFields(tester, controllerKey: key, - expectTopicTextField: false); + checkComposeBoxTextFields(tester, expectTopicTextField: false); }); }); @@ -354,8 +351,7 @@ void main() { }); testWidgets('selection change sends a "typing started" notice', (tester) async { - final controllerKey = await prepareComposeBox(tester, narrow: narrow, streams: [channel]); - final composeBoxController = controllerKey.currentState!; + await prepareComposeBox(tester, narrow: narrow, streams: [channel]); await checkStartTyping(tester, narrow); @@ -364,7 +360,7 @@ void main() { checkTypingRequest(TypingOp.stop, narrow); connection.prepare(json: {}); - composeBoxController.contentController.selection = + controller!.content.selection = const TextSelection(baseOffset: 0, extentOffset: 2); checkTypingRequest(TypingOp.start, narrow); @@ -474,14 +470,12 @@ void main() { final channel = eg.stream(); final narrow = ChannelNarrow(channel.streamId); - final controllerKey = await prepareComposeBox(tester, - narrow: narrow, streams: [channel]); - final composeBoxController = controllerKey.currentState!; + await prepareComposeBox(tester, narrow: narrow, streams: [channel]); // (When we check that the send button looks disabled, it should be because // the file is uploading, not a pre-existing reason.) await enterTopic(tester, narrow: narrow, topic: 'some topic'); - composeBoxController.contentController.value = const TextEditingValue(text: 'see image: '); + controller!.content.value = const TextEditingValue(text: 'see image: '); await tester.pump(); checkAppearsLoading(tester, false); @@ -505,7 +499,7 @@ void main() { final errorDialogs = tester.widgetList(find.byType(AlertDialog)); check(errorDialogs).isEmpty(); - check(composeBoxController.contentController.text) + check(controller!.content.text) .equals('see image: [Uploading image.jpg…]()\n\n'); // (the request is checked more thoroughly in API tests) check(connection.lastRequest!).isA() @@ -521,7 +515,7 @@ void main() { checkAppearsLoading(tester, true); await tester.pump(const Duration(seconds: 1)); - check(composeBoxController.contentController.text) + check(controller!.content.text) .equals('see image: [image.jpg](/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg)\n\n'); checkAppearsLoading(tester, false); }); @@ -536,14 +530,12 @@ void main() { final channel = eg.stream(); final narrow = ChannelNarrow(channel.streamId); - final controllerKey = await prepareComposeBox(tester, - narrow: narrow, streams: [channel]); - final composeBoxController = controllerKey.currentState!; + await prepareComposeBox(tester, narrow: narrow, streams: [channel]); // (When we check that the send button looks disabled, it should be because // the file is uploading, not a pre-existing reason.) await enterTopic(tester, narrow: narrow, topic: 'some topic'); - composeBoxController.contentController.value = const TextEditingValue(text: 'see image: '); + controller!.content.value = const TextEditingValue(text: 'see image: '); await tester.pump(); checkAppearsLoading(tester, false); @@ -567,7 +559,7 @@ void main() { final errorDialogs = tester.widgetList(find.byType(AlertDialog)); check(errorDialogs).isEmpty(); - check(composeBoxController.contentController.text) + check(controller!.content.text) .equals('see image: [Uploading image.jpg…]()\n\n'); // (the request is checked more thoroughly in API tests) check(connection.lastRequest!).isA() @@ -583,7 +575,7 @@ void main() { checkAppearsLoading(tester, true); await tester.pump(const Duration(seconds: 1)); - check(composeBoxController.contentController.text) + check(controller!.content.text) .equals('see image: [image.jpg](/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg)\n\n'); checkAppearsLoading(tester, false); });