From d5be0d4cc9e4b87c3ed06a156e608d0e356316f4 Mon Sep 17 00:00:00 2001 From: ARYPROGRAMMER Date: Sat, 28 Dec 2024 05:37:32 +0530 Subject: [PATCH 1/8] feat: prevent typing over-long topic names Signed-off-by: ARYPROGRAMMER --- lib/widgets/compose_box.dart | 482 +++++++++++++++++++---------------- 1 file changed, 267 insertions(+), 215 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 114e392bc7..42ec70fedf 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -67,13 +67,30 @@ enum TopicValidationError { class ComposeTopicController extends ComposeController { ComposeTopicController() { + addListener(_enforceCharacterLimit); _update(); } + static final characterCount = ValueNotifier(0); // TODO: subscribe to this value: // https://zulip.com/help/require-topics final mandatory = true; + void _enforceCharacterLimit() { + if (text.length > kMaxTopicLength) { + // Truncate text to `kMaxTopicLength` + final newText = text.substring(0, kMaxTopicLength); + + // Update controller value and selection (sync with TextField) + value = value.copyWith( + text: newText, + selection: TextSelection.collapsed(offset: newText.length), + ); + } + // Update the character count + characterCount.value = text.length.clamp(0, kMaxTopicLength); + } + @override String _computeTextNormalized() { String trimmed = text.trim(); @@ -197,8 +214,8 @@ class ComposeContentController extends ComposeController assert(val != null, 'registerQuoteAndReplyEnd called twice for same tag'); final int startIndex = text.indexOf(val!.placeholder); final replacementText = rawContent == null - ? '' - : quoteAndReply(store, message: message, rawContent: rawContent); + ? '' + : quoteAndReply(store, message: message, rawContent: rawContent); if (startIndex >= 0) { value = value.replaced( TextRange(start: startIndex, end: startIndex + val.placeholder.length), @@ -236,12 +253,12 @@ class ComposeContentController extends ComposeController final (:filename, :placeholder) = val!; final int startIndex = text.indexOf(placeholder); final replacementRange = startIndex >= 0 - ? TextRange(start: startIndex, end: startIndex + placeholder.length) - : insertionIndex(); + ? TextRange(start: startIndex, end: startIndex + placeholder.length) + : insertionIndex(); value = value.replaced( - replacementRange, - url == null ? '' : inlineLink(filename, url)); + replacementRange, + url == null ? '' : inlineLink(filename, url)); _uploads.remove(tag); notifyListeners(); // _uploads change could affect validationErrors } @@ -320,8 +337,8 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve void _contentChanged() { final store = PerAccountStoreWidget.of(context); (widget.controller.content.text.isEmpty) - ? store.typingNotifier.stoppedComposing() - : store.typingNotifier.keystroke(widget.destination); + ? store.typingNotifier.stoppedComposing() + : store.typingNotifier.keystroke(widget.destination); } void _focusChanged() { @@ -340,31 +357,31 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve case AppLifecycleState.hidden: case AppLifecycleState.paused: case AppLifecycleState.detached: - // Transition to either [hidden] or [paused] signals that - // > [the] application is not currently visible to the user, and not - // > responding to user input. - // - // When transitioning to [detached], the compose box can't exist: - // > The application defaults to this state before it initializes, and - // > can be in this state (applicable on Android, iOS, and web) after - // > all views have been detached. - // - // For all these states, we can conclude that the user is not - // composing a message. + // Transition to either [hidden] or [paused] signals that + // > [the] application is not currently visible to the user, and not + // > responding to user input. + // + // When transitioning to [detached], the compose box can't exist: + // > The application defaults to this state before it initializes, and + // > can be in this state (applicable on Android, iOS, and web) after + // > all views have been detached. + // + // For all these states, we can conclude that the user is not + // composing a message. final store = PerAccountStoreWidget.of(context); store.typingNotifier.stoppedComposing(); case AppLifecycleState.inactive: - // > At least one view of the application is visible, but none have - // > input focus. The application is otherwise running normally. - // For example, we expect this state when the user is selecting a file - // to upload. + // > At least one view of the application is visible, but none have + // > input focus. The application is otherwise running normally. + // For example, we expect this state when the user is selecting a file + // to upload. case AppLifecycleState.resumed: } } static double maxHeight(BuildContext context) { final clampingTextScaler = MediaQuery.textScalerOf(context) - .clamp(maxScaleFactor: 1.5); + .clamp(maxScaleFactor: 1.5); final scaledLineHeight = clampingTextScaler.scale(_fontSize) * _lineHeightRatio; // Reserve space to fully show the first 7th lines and just partially @@ -392,47 +409,47 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve final designVariables = DesignVariables.of(context); return ComposeAutocomplete( - narrow: widget.narrow, - 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. - child: ClipRect( - child: InsetShadowBox( - top: _verticalPadding, bottom: _verticalPadding, - color: designVariables.composeBoxBg, - child: TextField( - 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, - style: TextStyle( - fontSize: _fontSize, - height: _lineHeightRatio, - color: designVariables.textInput), - // From the spec at - // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3960-5147&node-type=text&m=dev - // > Compose box has the height to fit 2 lines. This is [done] to - // > have a bigger hit area for the user to start the input. […] - minLines: 2, - maxLines: null, - textCapitalization: TextCapitalization.sentences, - decoration: InputDecoration( - // This padding ensures that the user can always scroll long - // content entirely out of the top or bottom shadow if desired. - // With this and the `minLines: 2` above, an empty content input - // gets 60px vertical distance (with no text-size scaling) - // between the top of the top shadow and the bottom of the - // bottom shadow. That's a bit more than the 54px given in the - // Figma, and we can revisit if needed, but it's tricky to get - // that 54px distance while also making the scrolling work like - // this and offering two lines of touchable area. - contentPadding: const EdgeInsets.symmetric(vertical: _verticalPadding), - hintText: widget.hintText, - hintStyle: TextStyle( - color: designVariables.textInput.withFadedAlpha(0.5)))))))); + narrow: widget.narrow, + 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. + child: ClipRect( + child: InsetShadowBox( + top: _verticalPadding, bottom: _verticalPadding, + color: designVariables.composeBoxBg, + child: TextField( + 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, + style: TextStyle( + fontSize: _fontSize, + height: _lineHeightRatio, + color: designVariables.textInput), + // From the spec at + // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3960-5147&node-type=text&m=dev + // > Compose box has the height to fit 2 lines. This is [done] to + // > have a bigger hit area for the user to start the input. […] + minLines: 2, + maxLines: null, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + // This padding ensures that the user can always scroll long + // content entirely out of the top or bottom shadow if desired. + // With this and the `minLines: 2` above, an empty content input + // gets 60px vertical distance (with no text-size scaling) + // between the top of the top shadow and the bottom of the + // bottom shadow. That's a bit more than the 54px given in the + // Figma, and we can revisit if needed, but it's tricky to get + // that 54px distance while also making the scrolling work like + // this and offering two lines of touchable area. + contentPadding: const EdgeInsets.symmetric(vertical: _verticalPadding), + hintText: widget.hintText, + hintStyle: TextStyle( + color: designVariables.textInput.withFadedAlpha(0.5)))))))); } } @@ -483,12 +500,12 @@ class _StreamContentInputState extends State<_StreamContentInput> { final store = PerAccountStoreWidget.of(context); final zulipLocalizations = ZulipLocalizations.of(context); final streamName = store.streams[widget.narrow.streamId]?.name - ?? zulipLocalizations.composeBoxUnknownChannelName; + ?? zulipLocalizations.composeBoxUnknownChannelName; return _ContentInput( - narrow: widget.narrow, - destination: TopicNarrow(widget.narrow.streamId, _topicTextNormalized), - controller: widget.controller, - hintText: zulipLocalizations.composeBoxChannelContentHint(streamName, _topicTextNormalized)); + narrow: widget.narrow, + destination: TopicNarrow(widget.narrow.streamId, _topicTextNormalized), + controller: widget.controller, + hintText: zulipLocalizations.composeBoxChannelContentHint(streamName, _topicTextNormalized)); } } @@ -515,18 +532,53 @@ class _TopicInput extends StatelessWidget { 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.topic, - focusNode: controller.topicFocusNode, - textInputAction: TextInputAction.next, - style: topicTextStyle, - decoration: InputDecoration( - hintText: zulipLocalizations.composeBoxTopicHintText, - hintStyle: topicTextStyle.copyWith( - color: designVariables.textInput.withFadedAlpha(0.5)))))); + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + width: 1, + color: designVariables.foreground.withFadedAlpha(0.2), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, // Aligns text elements to the start + children: [ + ValueListenableBuilder( + valueListenable: ComposeTopicController.characterCount, + builder: (context, count, child) { + return Text( + '$count / $kMaxTopicLength', + style: TextStyle( + fontSize: 12, + color: count >= kMaxTopicLength + ? designVariables.btnLabelAttMediumIntDanger + : designVariables.foreground.withFadedAlpha(0.5), + ), + ); + }, + ), + TextField( + controller: controller.topic, + focusNode: controller.topicFocusNode, + textInputAction: TextInputAction.next, + style: topicTextStyle, + decoration: InputDecoration( + contentPadding: EdgeInsets.zero, // Removes any padding + hintText: zulipLocalizations.composeBoxTopicHintText, + hintStyle: topicTextStyle.copyWith( + color: designVariables.textInput.withFadedAlpha(0.5), + ), + ), + inputFormatters: [ + LengthLimitingTextInputFormatter(kMaxTopicLength), + ], + ), + + ], + ), + + ), + ); } } @@ -545,7 +597,7 @@ class _FixedDestinationContentInput extends StatelessWidget { case TopicNarrow(:final streamId, :final topic): final store = PerAccountStoreWidget.of(context); final streamName = store.streams[streamId]?.name - ?? zulipLocalizations.composeBoxUnknownChannelName; + ?? zulipLocalizations.composeBoxUnknownChannelName; return zulipLocalizations.composeBoxChannelContentHint(streamName, topic); case DmNarrow(otherRecipientIds: []): // The self-1:1 thread. @@ -565,10 +617,10 @@ class _FixedDestinationContentInput extends StatelessWidget { @override Widget build(BuildContext context) { return _ContentInput( - narrow: narrow, - destination: narrow, - controller: controller, - hintText: _hintText(context)); + narrow: narrow, + destination: narrow, + controller: controller, + hintText: _hintText(context)); } } @@ -612,21 +664,21 @@ Future _uploadFiles({ if (tooLargeFiles.isNotEmpty) { final listMessage = tooLargeFiles - .map((file) => '${file.filename}: ${(file.length / (1 << 20)).toStringAsFixed(1)} MiB') - .join('\n'); + .map((file) => '${file.filename}: ${(file.length / (1 << 20)).toStringAsFixed(1)} MiB') + .join('\n'); showErrorDialog( - context: context, - title: zulipLocalizations.errorFilesTooLargeTitle(tooLargeFiles.length), - message: zulipLocalizations.errorFilesTooLarge( - tooLargeFiles.length, - store.maxFileUploadSizeMib, - listMessage)); + context: context, + title: zulipLocalizations.errorFilesTooLargeTitle(tooLargeFiles.length), + message: zulipLocalizations.errorFilesTooLarge( + tooLargeFiles.length, + store.maxFileUploadSizeMib, + listMessage)); } final List<(int, _File)> uploadsInProgress = []; for (final file in rightSizeFiles) { final tag = contentController.registerUploadStart(file.filename, - zulipLocalizations); + zulipLocalizations); uploadsInProgress.add((tag, file)); } if (!contentFocusNode.hasFocus) { @@ -649,8 +701,8 @@ Future _uploadFiles({ // TODO(#741): Specifically handle `413 Payload Too Large` // TODO(#741): On API errors, quote `msg` from server, with "The server said:" showErrorDialog(context: context, - title: zulipLocalizations.errorFailedToUploadFileTitle(filename), - message: e.toString()); + title: zulipLocalizations.errorFailedToUploadFileTitle(filename), + message: e.toString()); } finally { contentController.registerUploadEnd(tag, url); } @@ -687,10 +739,10 @@ abstract class _AttachUploadsButton extends StatelessWidget { } await _uploadFiles( - context: context, - contentController: controller.content, - contentFocusNode: controller.contentFocusNode, - files: files); + context: context, + contentController: controller.content, + contentFocusNode: controller.contentFocusNode, + files: files); } @override @@ -698,11 +750,11 @@ abstract class _AttachUploadsButton extends StatelessWidget { final designVariables = DesignVariables.of(context); final zulipLocalizations = ZulipLocalizations.of(context); return SizedBox( - width: _composeButtonSize, - child: IconButton( - icon: Icon(icon, color: designVariables.foreground.withFadedAlpha(0.5)), - tooltip: tooltip(zulipLocalizations), - onPressed: () => _handlePress(context))); + width: _composeButtonSize, + child: IconButton( + icon: Icon(icon, color: designVariables.foreground.withFadedAlpha(0.5)), + tooltip: tooltip(zulipLocalizations), + onPressed: () => _handlePress(context))); } } @@ -710,7 +762,7 @@ Future> _getFilePickerFiles(BuildContext context, FileType type) FilePickerResult? result; try { result = await ZulipBinding.instance - .pickFiles(allowMultiple: true, withReadStream: true, type: type); + .pickFiles(allowMultiple: true, withReadStream: true, type: type); } catch (e) { if (!context.mounted) return []; final zulipLocalizations = ZulipLocalizations.of(context); @@ -722,16 +774,16 @@ Future> _getFilePickerFiles(BuildContext context, FileType type) // our prompt and retry, and the permissions request will reappear, // letting them grant permissions and complete the upload. showSuggestedActionDialog(context: context, - title: zulipLocalizations.permissionsNeededTitle, - message: zulipLocalizations.permissionsDeniedReadExternalStorage, - actionButtonText: zulipLocalizations.permissionsNeededOpenSettings, - onActionButtonPress: () { - AppSettings.openAppSettings(); - }); + title: zulipLocalizations.permissionsNeededTitle, + message: zulipLocalizations.permissionsDeniedReadExternalStorage, + actionButtonText: zulipLocalizations.permissionsNeededOpenSettings, + onActionButtonPress: () { + AppSettings.openAppSettings(); + }); } else { showErrorDialog(context: context, - title: zulipLocalizations.errorDialogTitle, - message: e.toString()); + title: zulipLocalizations.errorDialogTitle, + message: e.toString()); } return []; } @@ -768,7 +820,7 @@ class _AttachFileButton extends _AttachUploadsButton { @override String tooltip(ZulipLocalizations zulipLocalizations) => - zulipLocalizations.composeBoxAttachFilesTooltip; + zulipLocalizations.composeBoxAttachFilesTooltip; @override Future> getFiles(BuildContext context) async { @@ -784,7 +836,7 @@ class _AttachMediaButton extends _AttachUploadsButton { @override String tooltip(ZulipLocalizations zulipLocalizations) => - zulipLocalizations.composeBoxAttachMediaTooltip; + zulipLocalizations.composeBoxAttachMediaTooltip; @override Future> getFiles(BuildContext context) async { @@ -814,7 +866,7 @@ class _AttachFromCameraButton extends _AttachUploadsButton { // so just stick with images for now. We could add another button for // videos, but we don't want too many buttons. result = await ZulipBinding.instance.pickImage( - source: ImageSource.camera, requestFullMetadata: false); + source: ImageSource.camera, requestFullMetadata: false); } catch (e) { if (!context.mounted) return []; if (e is PlatformException && e.code == 'camera_access_denied') { @@ -823,16 +875,16 @@ class _AttachFromCameraButton extends _AttachUploadsButton { // use a protected resource. After that, the only way the user can // grant it is in Settings. showSuggestedActionDialog(context: context, - title: zulipLocalizations.permissionsNeededTitle, - message: zulipLocalizations.permissionsDeniedCameraAccess, - actionButtonText: zulipLocalizations.permissionsNeededOpenSettings, - onActionButtonPress: () { - AppSettings.openAppSettings(); - }); + title: zulipLocalizations.permissionsNeededTitle, + message: zulipLocalizations.permissionsDeniedCameraAccess, + actionButtonText: zulipLocalizations.permissionsNeededOpenSettings, + onActionButtonPress: () { + AppSettings.openAppSettings(); + }); } else { showErrorDialog(context: context, - title: zulipLocalizations.errorDialogTitle, - message: e.toString()); + title: zulipLocalizations.errorDialogTitle, + message: e.toString()); } return []; } @@ -844,13 +896,13 @@ class _AttachFromCameraButton extends _AttachUploadsButton { List? headerBytes; try { headerBytes = await result.openRead( - 0, - // Despite its dartdoc, [XFile.openRead] can throw if `end` is greater - // than the file's length. We can *probably* trust our `length` to be - // accurate, but it's nontrivial to verify. If it's inaccurate, we'd - // rather sacrifice this part of the MIME lookup than throw the whole - // upload. So, the try/catch. - min(defaultMagicNumbersMaxLength, length) + 0, + // Despite its dartdoc, [XFile.openRead] can throw if `end` is greater + // than the file's length. We can *probably* trust our `length` to be + // accurate, but it's nontrivial to verify. If it's inaccurate, we'd + // rather sacrifice this part of the MIME lookup than throw the whole + // upload. So, the try/catch. + min(defaultMagicNumbersMaxLength, length) ).expand((l) => l).toList(); } catch (e) { // TODO(log) @@ -860,7 +912,7 @@ class _AttachFromCameraButton extends _AttachUploadsButton { length: length, filename: result.name, mimeType: result.mimeType - ?? lookupMimeType(result.path, headerBytes: headerBytes), + ?? lookupMimeType(result.path, headerBytes: headerBytes), )]; } } @@ -937,16 +989,16 @@ class _SendButtonState extends State<_SendButton> { final zulipLocalizations = ZulipLocalizations.of(context); List validationErrorMessages = [ for (final error in (controller is StreamComposeBoxController - ? controller.topic.validationErrors - : const [])) + ? controller.topic.validationErrors + : const [])) error.message(zulipLocalizations), for (final error in controller.content.validationErrors) error.message(zulipLocalizations), ]; showErrorDialog( - context: context, - title: zulipLocalizations.errorMessageNotSent, - message: validationErrorMessages.join('\n\n')); + context: context, + title: zulipLocalizations.errorMessageNotSent, + message: validationErrorMessages.join('\n\n')); return; } @@ -972,8 +1024,8 @@ class _SendButtonState extends State<_SendButton> { _ => e.message, }; showErrorDialog(context: context, - title: zulipLocalizations.errorMessageNotSent, - message: message); + title: zulipLocalizations.errorMessageNotSent, + message: message); return; } } @@ -984,20 +1036,20 @@ class _SendButtonState extends State<_SendButton> { final zulipLocalizations = ZulipLocalizations.of(context); final iconColor = _hasValidationErrors - ? designVariables.icon.withFadedAlpha(0.5) - : designVariables.icon; + ? designVariables.icon.withFadedAlpha(0.5) + : designVariables.icon; return SizedBox( - width: _composeButtonSize, - child: IconButton( - tooltip: zulipLocalizations.composeBoxSendTooltip, - icon: Icon(ZulipIcons.send, - // We set [Icon.color] instead of [IconButton.color] because the - // latter implicitly uses colors derived from it to override the - // ambient [ButtonStyle.overlayColor], where we set the color for - // the highlight state to match the Figma design. - color: iconColor), - onPressed: _send)); + width: _composeButtonSize, + child: IconButton( + tooltip: zulipLocalizations.composeBoxSendTooltip, + icon: Icon(ZulipIcons.send, + // We set [Icon.color] instead of [IconButton.color] because the + // latter implicitly uses colors derived from it to override the + // ambient [ButtonStyle.overlayColor], where we set the color for + // the highlight state to match the Figma design. + color: iconColor), + onPressed: _send)); } } @@ -1027,7 +1079,7 @@ class _ComposeBoxContainer extends StatelessWidget { Widget _paddedBody() { assert(body != null); return SafeArea(minimum: const EdgeInsets.symmetric(horizontal: 8), - child: body!); + child: body!); } @override @@ -1039,7 +1091,7 @@ class _ComposeBoxContainer extends StatelessWidget { // _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!), + child: errorBanner!), _paddedBody(), ], (Widget(), null) => [errorBanner!], @@ -1050,13 +1102,13 @@ class _ComposeBoxContainer extends StatelessWidget { // 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: Column( - children: children))); + 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: Column( + children: children))); } } @@ -1077,23 +1129,23 @@ abstract class _ComposeBoxBody extends StatelessWidget { final designVariables = DesignVariables.of(context); final inputThemeData = themeData.copyWith( - inputDecorationTheme: const InputDecorationTheme( - // Both [contentPadding] and [isDense] combine to make the layout compact. - isDense: true, - contentPadding: EdgeInsets.zero, - border: InputBorder.none)); + inputDecorationTheme: const InputDecorationTheme( + // Both [contentPadding] and [isDense] combine to make the layout compact. + isDense: true, + contentPadding: EdgeInsets.zero, + border: InputBorder.none)); // TODO(#417): Disable splash effects for all buttons globally. final iconButtonThemeData = IconButtonThemeData( - style: IconButton.styleFrom( - splashFactory: NoSplash.splashFactory, - // TODO(#417): The Figma design specifies a different icon color on - // pressed, but `IconButton` currently does not have support for - // that. See also: - // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3707-41711&node-type=frame&t=sSYomsJzGCt34D8N-0 - highlightColor: designVariables.editorButtonPressedBg, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(4))))); + style: IconButton.styleFrom( + splashFactory: NoSplash.splashFactory, + // TODO(#417): The Figma design specifies a different icon color on + // pressed, but `IconButton` currently does not have support for + // that. See also: + // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3707-41711&node-type=frame&t=sSYomsJzGCt34D8N-0 + highlightColor: designVariables.editorButtonPressedBg, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(4))))); final composeButtons = [ _AttachFileButton(controller: controller), @@ -1104,23 +1156,23 @@ abstract class _ComposeBoxBody extends StatelessWidget { 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(), - ]))), + 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(), - ]))), + height: _composeButtonSize, + child: IconButtonTheme( + data: iconButtonThemeData, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row(children: composeButtons), + buildSendButton(), + ]))), ]); } } @@ -1151,7 +1203,7 @@ class _StreamComposeBoxBody extends _ComposeBoxBody { @override Widget buildSendButton() => _SendButton( controller: controller, getDestination: () => StreamDestination( - narrow.streamId, controller.topic.textNormalized), + narrow.streamId, controller.topic.textNormalized), ); } @@ -1217,29 +1269,29 @@ class _ErrorBanner extends StatelessWidget { ).merge(weightVariableTextStyle(context, wght: 600)); return DecoratedBox( - decoration: BoxDecoration( - 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? - ]))); + decoration: BoxDecoration( + 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 ComposeBox extends StatefulWidget { ComposeBox({super.key, required this.narrow}) - : assert(ComposeBox.hasComposeBox(narrow)); + : assert(ComposeBox.hasComposeBox(narrow)); final Narrow narrow; @@ -1302,14 +1354,14 @@ class _ComposeBoxState extends State implements ComposeBoxState { if (channel == null || !store.hasPostingPermission(inChannel: channel, user: selfUser, byDate: DateTime.now())) { return _ErrorBanner(label: - ZulipLocalizations.of(context).errorBannerCannotPostInChannelLabel); + ZulipLocalizations.of(context).errorBannerCannotPostInChannelLabel); } case DmNarrow(:final otherRecipientIds): final hasDeactivatedUser = otherRecipientIds.any((id) => - !(store.users[id]?.isActive ?? true)); + !(store.users[id]?.isActive ?? true)); if (hasDeactivatedUser) { return _ErrorBanner(label: - ZulipLocalizations.of(context).errorBannerDeactivatedDmLabel); + ZulipLocalizations.of(context).errorBannerDeactivatedDmLabel); } case CombinedFeedNarrow(): case MentionsNarrow(): From be5f0e8e2f367fa894debcad6004ecb74f236ba7 Mon Sep 17 00:00:00 2001 From: ARYPROGRAMMER Date: Sat, 28 Dec 2024 06:52:58 +0530 Subject: [PATCH 2/8] fix auto formatting Signed-off-by: ARYPROGRAMMER --- lib/widgets/compose_box.dart | 448 +++++++++++++++++------------------ 1 file changed, 219 insertions(+), 229 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 42ec70fedf..0e11be0725 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -71,25 +71,23 @@ class ComposeTopicController extends ComposeController { _update(); } static final characterCount = ValueNotifier(0); - // TODO: subscribe to this value: // https://zulip.com/help/require-topics final mandatory = true; - void _enforceCharacterLimit() { - if (text.length > kMaxTopicLength) { - // Truncate text to `kMaxTopicLength` - final newText = text.substring(0, kMaxTopicLength); - - // Update controller value and selection (sync with TextField) - value = value.copyWith( - text: newText, - selection: TextSelection.collapsed(offset: newText.length), - ); + if (text.length > kMaxTopicLength) { + // Truncate text to `kMaxTopicLength` + final newText = text.substring(0, kMaxTopicLength); + + // Update controller value and selection (sync with TextField) + value = value.copyWith( + text: newText, + selection: TextSelection.collapsed(offset: newText.length), + ); + } + // Update the character count + characterCount.value = text.length.clamp(0, kMaxTopicLength); } - // Update the character count - characterCount.value = text.length.clamp(0, kMaxTopicLength); - } @override String _computeTextNormalized() { @@ -214,8 +212,8 @@ class ComposeContentController extends ComposeController assert(val != null, 'registerQuoteAndReplyEnd called twice for same tag'); final int startIndex = text.indexOf(val!.placeholder); final replacementText = rawContent == null - ? '' - : quoteAndReply(store, message: message, rawContent: rawContent); + ? '' + : quoteAndReply(store, message: message, rawContent: rawContent); if (startIndex >= 0) { value = value.replaced( TextRange(start: startIndex, end: startIndex + val.placeholder.length), @@ -253,12 +251,12 @@ class ComposeContentController extends ComposeController final (:filename, :placeholder) = val!; final int startIndex = text.indexOf(placeholder); final replacementRange = startIndex >= 0 - ? TextRange(start: startIndex, end: startIndex + placeholder.length) - : insertionIndex(); + ? TextRange(start: startIndex, end: startIndex + placeholder.length) + : insertionIndex(); value = value.replaced( - replacementRange, - url == null ? '' : inlineLink(filename, url)); + replacementRange, + url == null ? '' : inlineLink(filename, url)); _uploads.remove(tag); notifyListeners(); // _uploads change could affect validationErrors } @@ -337,8 +335,8 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve void _contentChanged() { final store = PerAccountStoreWidget.of(context); (widget.controller.content.text.isEmpty) - ? store.typingNotifier.stoppedComposing() - : store.typingNotifier.keystroke(widget.destination); + ? store.typingNotifier.stoppedComposing() + : store.typingNotifier.keystroke(widget.destination); } void _focusChanged() { @@ -357,31 +355,31 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve case AppLifecycleState.hidden: case AppLifecycleState.paused: case AppLifecycleState.detached: - // Transition to either [hidden] or [paused] signals that - // > [the] application is not currently visible to the user, and not - // > responding to user input. - // - // When transitioning to [detached], the compose box can't exist: - // > The application defaults to this state before it initializes, and - // > can be in this state (applicable on Android, iOS, and web) after - // > all views have been detached. - // - // For all these states, we can conclude that the user is not - // composing a message. + // Transition to either [hidden] or [paused] signals that + // > [the] application is not currently visible to the user, and not + // > responding to user input. + // + // When transitioning to [detached], the compose box can't exist: + // > The application defaults to this state before it initializes, and + // > can be in this state (applicable on Android, iOS, and web) after + // > all views have been detached. + // + // For all these states, we can conclude that the user is not + // composing a message. final store = PerAccountStoreWidget.of(context); store.typingNotifier.stoppedComposing(); case AppLifecycleState.inactive: - // > At least one view of the application is visible, but none have - // > input focus. The application is otherwise running normally. - // For example, we expect this state when the user is selecting a file - // to upload. + // > At least one view of the application is visible, but none have + // > input focus. The application is otherwise running normally. + // For example, we expect this state when the user is selecting a file + // to upload. case AppLifecycleState.resumed: } } static double maxHeight(BuildContext context) { final clampingTextScaler = MediaQuery.textScalerOf(context) - .clamp(maxScaleFactor: 1.5); + .clamp(maxScaleFactor: 1.5); final scaledLineHeight = clampingTextScaler.scale(_fontSize) * _lineHeightRatio; // Reserve space to fully show the first 7th lines and just partially @@ -409,47 +407,47 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve final designVariables = DesignVariables.of(context); return ComposeAutocomplete( - narrow: widget.narrow, - 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. - child: ClipRect( - child: InsetShadowBox( - top: _verticalPadding, bottom: _verticalPadding, - color: designVariables.composeBoxBg, - child: TextField( - 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, - style: TextStyle( - fontSize: _fontSize, - height: _lineHeightRatio, - color: designVariables.textInput), - // From the spec at - // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3960-5147&node-type=text&m=dev - // > Compose box has the height to fit 2 lines. This is [done] to - // > have a bigger hit area for the user to start the input. […] - minLines: 2, - maxLines: null, - textCapitalization: TextCapitalization.sentences, - decoration: InputDecoration( - // This padding ensures that the user can always scroll long - // content entirely out of the top or bottom shadow if desired. - // With this and the `minLines: 2` above, an empty content input - // gets 60px vertical distance (with no text-size scaling) - // between the top of the top shadow and the bottom of the - // bottom shadow. That's a bit more than the 54px given in the - // Figma, and we can revisit if needed, but it's tricky to get - // that 54px distance while also making the scrolling work like - // this and offering two lines of touchable area. - contentPadding: const EdgeInsets.symmetric(vertical: _verticalPadding), - hintText: widget.hintText, - hintStyle: TextStyle( - color: designVariables.textInput.withFadedAlpha(0.5)))))))); + narrow: widget.narrow, + 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. + child: ClipRect( + child: InsetShadowBox( + top: _verticalPadding, bottom: _verticalPadding, + color: designVariables.composeBoxBg, + child: TextField( + 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, + style: TextStyle( + fontSize: _fontSize, + height: _lineHeightRatio, + color: designVariables.textInput), + // From the spec at + // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3960-5147&node-type=text&m=dev + // > Compose box has the height to fit 2 lines. This is [done] to + // > have a bigger hit area for the user to start the input. […] + minLines: 2, + maxLines: null, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + // This padding ensures that the user can always scroll long + // content entirely out of the top or bottom shadow if desired. + // With this and the `minLines: 2` above, an empty content input + // gets 60px vertical distance (with no text-size scaling) + // between the top of the top shadow and the bottom of the + // bottom shadow. That's a bit more than the 54px given in the + // Figma, and we can revisit if needed, but it's tricky to get + // that 54px distance while also making the scrolling work like + // this and offering two lines of touchable area. + contentPadding: const EdgeInsets.symmetric(vertical: _verticalPadding), + hintText: widget.hintText, + hintStyle: TextStyle( + color: designVariables.textInput.withFadedAlpha(0.5)))))))); } } @@ -500,12 +498,12 @@ class _StreamContentInputState extends State<_StreamContentInput> { final store = PerAccountStoreWidget.of(context); final zulipLocalizations = ZulipLocalizations.of(context); final streamName = store.streams[widget.narrow.streamId]?.name - ?? zulipLocalizations.composeBoxUnknownChannelName; + ?? zulipLocalizations.composeBoxUnknownChannelName; return _ContentInput( - narrow: widget.narrow, - destination: TopicNarrow(widget.narrow.streamId, _topicTextNormalized), - controller: widget.controller, - hintText: zulipLocalizations.composeBoxChannelContentHint(streamName, _topicTextNormalized)); + narrow: widget.narrow, + destination: TopicNarrow(widget.narrow.streamId, _topicTextNormalized), + controller: widget.controller, + hintText: zulipLocalizations.composeBoxChannelContentHint(streamName, _topicTextNormalized)); } } @@ -532,14 +530,9 @@ class _TopicInput extends StatelessWidget { 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), - ), - ), - ), + decoration: BoxDecoration(border: Border(bottom: BorderSide( + width: 1, + color: designVariables.foreground.withFadedAlpha(0.2)))), child: Column( crossAxisAlignment: CrossAxisAlignment.start, // Aligns text elements to the start children: [ @@ -575,10 +568,7 @@ class _TopicInput extends StatelessWidget { ), ], - ), - - ), - ); + ))); } } @@ -597,7 +587,7 @@ class _FixedDestinationContentInput extends StatelessWidget { case TopicNarrow(:final streamId, :final topic): final store = PerAccountStoreWidget.of(context); final streamName = store.streams[streamId]?.name - ?? zulipLocalizations.composeBoxUnknownChannelName; + ?? zulipLocalizations.composeBoxUnknownChannelName; return zulipLocalizations.composeBoxChannelContentHint(streamName, topic); case DmNarrow(otherRecipientIds: []): // The self-1:1 thread. @@ -617,10 +607,10 @@ class _FixedDestinationContentInput extends StatelessWidget { @override Widget build(BuildContext context) { return _ContentInput( - narrow: narrow, - destination: narrow, - controller: controller, - hintText: _hintText(context)); + narrow: narrow, + destination: narrow, + controller: controller, + hintText: _hintText(context)); } } @@ -664,21 +654,21 @@ Future _uploadFiles({ if (tooLargeFiles.isNotEmpty) { final listMessage = tooLargeFiles - .map((file) => '${file.filename}: ${(file.length / (1 << 20)).toStringAsFixed(1)} MiB') - .join('\n'); + .map((file) => '${file.filename}: ${(file.length / (1 << 20)).toStringAsFixed(1)} MiB') + .join('\n'); showErrorDialog( - context: context, - title: zulipLocalizations.errorFilesTooLargeTitle(tooLargeFiles.length), - message: zulipLocalizations.errorFilesTooLarge( - tooLargeFiles.length, - store.maxFileUploadSizeMib, - listMessage)); + context: context, + title: zulipLocalizations.errorFilesTooLargeTitle(tooLargeFiles.length), + message: zulipLocalizations.errorFilesTooLarge( + tooLargeFiles.length, + store.maxFileUploadSizeMib, + listMessage)); } final List<(int, _File)> uploadsInProgress = []; for (final file in rightSizeFiles) { final tag = contentController.registerUploadStart(file.filename, - zulipLocalizations); + zulipLocalizations); uploadsInProgress.add((tag, file)); } if (!contentFocusNode.hasFocus) { @@ -701,8 +691,8 @@ Future _uploadFiles({ // TODO(#741): Specifically handle `413 Payload Too Large` // TODO(#741): On API errors, quote `msg` from server, with "The server said:" showErrorDialog(context: context, - title: zulipLocalizations.errorFailedToUploadFileTitle(filename), - message: e.toString()); + title: zulipLocalizations.errorFailedToUploadFileTitle(filename), + message: e.toString()); } finally { contentController.registerUploadEnd(tag, url); } @@ -739,10 +729,10 @@ abstract class _AttachUploadsButton extends StatelessWidget { } await _uploadFiles( - context: context, - contentController: controller.content, - contentFocusNode: controller.contentFocusNode, - files: files); + context: context, + contentController: controller.content, + contentFocusNode: controller.contentFocusNode, + files: files); } @override @@ -750,11 +740,11 @@ abstract class _AttachUploadsButton extends StatelessWidget { final designVariables = DesignVariables.of(context); final zulipLocalizations = ZulipLocalizations.of(context); return SizedBox( - width: _composeButtonSize, - child: IconButton( - icon: Icon(icon, color: designVariables.foreground.withFadedAlpha(0.5)), - tooltip: tooltip(zulipLocalizations), - onPressed: () => _handlePress(context))); + width: _composeButtonSize, + child: IconButton( + icon: Icon(icon, color: designVariables.foreground.withFadedAlpha(0.5)), + tooltip: tooltip(zulipLocalizations), + onPressed: () => _handlePress(context))); } } @@ -762,7 +752,7 @@ Future> _getFilePickerFiles(BuildContext context, FileType type) FilePickerResult? result; try { result = await ZulipBinding.instance - .pickFiles(allowMultiple: true, withReadStream: true, type: type); + .pickFiles(allowMultiple: true, withReadStream: true, type: type); } catch (e) { if (!context.mounted) return []; final zulipLocalizations = ZulipLocalizations.of(context); @@ -774,16 +764,16 @@ Future> _getFilePickerFiles(BuildContext context, FileType type) // our prompt and retry, and the permissions request will reappear, // letting them grant permissions and complete the upload. showSuggestedActionDialog(context: context, - title: zulipLocalizations.permissionsNeededTitle, - message: zulipLocalizations.permissionsDeniedReadExternalStorage, - actionButtonText: zulipLocalizations.permissionsNeededOpenSettings, - onActionButtonPress: () { - AppSettings.openAppSettings(); - }); + title: zulipLocalizations.permissionsNeededTitle, + message: zulipLocalizations.permissionsDeniedReadExternalStorage, + actionButtonText: zulipLocalizations.permissionsNeededOpenSettings, + onActionButtonPress: () { + AppSettings.openAppSettings(); + }); } else { showErrorDialog(context: context, - title: zulipLocalizations.errorDialogTitle, - message: e.toString()); + title: zulipLocalizations.errorDialogTitle, + message: e.toString()); } return []; } @@ -820,7 +810,7 @@ class _AttachFileButton extends _AttachUploadsButton { @override String tooltip(ZulipLocalizations zulipLocalizations) => - zulipLocalizations.composeBoxAttachFilesTooltip; + zulipLocalizations.composeBoxAttachFilesTooltip; @override Future> getFiles(BuildContext context) async { @@ -836,7 +826,7 @@ class _AttachMediaButton extends _AttachUploadsButton { @override String tooltip(ZulipLocalizations zulipLocalizations) => - zulipLocalizations.composeBoxAttachMediaTooltip; + zulipLocalizations.composeBoxAttachMediaTooltip; @override Future> getFiles(BuildContext context) async { @@ -866,7 +856,7 @@ class _AttachFromCameraButton extends _AttachUploadsButton { // so just stick with images for now. We could add another button for // videos, but we don't want too many buttons. result = await ZulipBinding.instance.pickImage( - source: ImageSource.camera, requestFullMetadata: false); + source: ImageSource.camera, requestFullMetadata: false); } catch (e) { if (!context.mounted) return []; if (e is PlatformException && e.code == 'camera_access_denied') { @@ -875,16 +865,16 @@ class _AttachFromCameraButton extends _AttachUploadsButton { // use a protected resource. After that, the only way the user can // grant it is in Settings. showSuggestedActionDialog(context: context, - title: zulipLocalizations.permissionsNeededTitle, - message: zulipLocalizations.permissionsDeniedCameraAccess, - actionButtonText: zulipLocalizations.permissionsNeededOpenSettings, - onActionButtonPress: () { - AppSettings.openAppSettings(); - }); + title: zulipLocalizations.permissionsNeededTitle, + message: zulipLocalizations.permissionsDeniedCameraAccess, + actionButtonText: zulipLocalizations.permissionsNeededOpenSettings, + onActionButtonPress: () { + AppSettings.openAppSettings(); + }); } else { showErrorDialog(context: context, - title: zulipLocalizations.errorDialogTitle, - message: e.toString()); + title: zulipLocalizations.errorDialogTitle, + message: e.toString()); } return []; } @@ -896,13 +886,13 @@ class _AttachFromCameraButton extends _AttachUploadsButton { List? headerBytes; try { headerBytes = await result.openRead( - 0, - // Despite its dartdoc, [XFile.openRead] can throw if `end` is greater - // than the file's length. We can *probably* trust our `length` to be - // accurate, but it's nontrivial to verify. If it's inaccurate, we'd - // rather sacrifice this part of the MIME lookup than throw the whole - // upload. So, the try/catch. - min(defaultMagicNumbersMaxLength, length) + 0, + // Despite its dartdoc, [XFile.openRead] can throw if `end` is greater + // than the file's length. We can *probably* trust our `length` to be + // accurate, but it's nontrivial to verify. If it's inaccurate, we'd + // rather sacrifice this part of the MIME lookup than throw the whole + // upload. So, the try/catch. + min(defaultMagicNumbersMaxLength, length) ).expand((l) => l).toList(); } catch (e) { // TODO(log) @@ -912,7 +902,7 @@ class _AttachFromCameraButton extends _AttachUploadsButton { length: length, filename: result.name, mimeType: result.mimeType - ?? lookupMimeType(result.path, headerBytes: headerBytes), + ?? lookupMimeType(result.path, headerBytes: headerBytes), )]; } } @@ -989,16 +979,16 @@ class _SendButtonState extends State<_SendButton> { final zulipLocalizations = ZulipLocalizations.of(context); List validationErrorMessages = [ for (final error in (controller is StreamComposeBoxController - ? controller.topic.validationErrors - : const [])) + ? controller.topic.validationErrors + : const [])) error.message(zulipLocalizations), for (final error in controller.content.validationErrors) error.message(zulipLocalizations), ]; showErrorDialog( - context: context, - title: zulipLocalizations.errorMessageNotSent, - message: validationErrorMessages.join('\n\n')); + context: context, + title: zulipLocalizations.errorMessageNotSent, + message: validationErrorMessages.join('\n\n')); return; } @@ -1024,8 +1014,8 @@ class _SendButtonState extends State<_SendButton> { _ => e.message, }; showErrorDialog(context: context, - title: zulipLocalizations.errorMessageNotSent, - message: message); + title: zulipLocalizations.errorMessageNotSent, + message: message); return; } } @@ -1036,20 +1026,20 @@ class _SendButtonState extends State<_SendButton> { final zulipLocalizations = ZulipLocalizations.of(context); final iconColor = _hasValidationErrors - ? designVariables.icon.withFadedAlpha(0.5) - : designVariables.icon; + ? designVariables.icon.withFadedAlpha(0.5) + : designVariables.icon; return SizedBox( - width: _composeButtonSize, - child: IconButton( - tooltip: zulipLocalizations.composeBoxSendTooltip, - icon: Icon(ZulipIcons.send, - // We set [Icon.color] instead of [IconButton.color] because the - // latter implicitly uses colors derived from it to override the - // ambient [ButtonStyle.overlayColor], where we set the color for - // the highlight state to match the Figma design. - color: iconColor), - onPressed: _send)); + width: _composeButtonSize, + child: IconButton( + tooltip: zulipLocalizations.composeBoxSendTooltip, + icon: Icon(ZulipIcons.send, + // We set [Icon.color] instead of [IconButton.color] because the + // latter implicitly uses colors derived from it to override the + // ambient [ButtonStyle.overlayColor], where we set the color for + // the highlight state to match the Figma design. + color: iconColor), + onPressed: _send)); } } @@ -1079,7 +1069,7 @@ class _ComposeBoxContainer extends StatelessWidget { Widget _paddedBody() { assert(body != null); return SafeArea(minimum: const EdgeInsets.symmetric(horizontal: 8), - child: body!); + child: body!); } @override @@ -1091,7 +1081,7 @@ class _ComposeBoxContainer extends StatelessWidget { // _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!), + child: errorBanner!), _paddedBody(), ], (Widget(), null) => [errorBanner!], @@ -1102,13 +1092,13 @@ class _ComposeBoxContainer extends StatelessWidget { // 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: Column( - children: children))); + 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: Column( + children: children))); } } @@ -1129,23 +1119,23 @@ abstract class _ComposeBoxBody extends StatelessWidget { final designVariables = DesignVariables.of(context); final inputThemeData = themeData.copyWith( - inputDecorationTheme: const InputDecorationTheme( - // Both [contentPadding] and [isDense] combine to make the layout compact. - isDense: true, - contentPadding: EdgeInsets.zero, - border: InputBorder.none)); + inputDecorationTheme: const InputDecorationTheme( + // Both [contentPadding] and [isDense] combine to make the layout compact. + isDense: true, + contentPadding: EdgeInsets.zero, + border: InputBorder.none)); // TODO(#417): Disable splash effects for all buttons globally. final iconButtonThemeData = IconButtonThemeData( - style: IconButton.styleFrom( - splashFactory: NoSplash.splashFactory, - // TODO(#417): The Figma design specifies a different icon color on - // pressed, but `IconButton` currently does not have support for - // that. See also: - // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3707-41711&node-type=frame&t=sSYomsJzGCt34D8N-0 - highlightColor: designVariables.editorButtonPressedBg, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(4))))); + style: IconButton.styleFrom( + splashFactory: NoSplash.splashFactory, + // TODO(#417): The Figma design specifies a different icon color on + // pressed, but `IconButton` currently does not have support for + // that. See also: + // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3707-41711&node-type=frame&t=sSYomsJzGCt34D8N-0 + highlightColor: designVariables.editorButtonPressedBg, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(4))))); final composeButtons = [ _AttachFileButton(controller: controller), @@ -1156,23 +1146,23 @@ abstract class _ComposeBoxBody extends StatelessWidget { 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(), - ]))), + 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(), - ]))), + height: _composeButtonSize, + child: IconButtonTheme( + data: iconButtonThemeData, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row(children: composeButtons), + buildSendButton(), + ]))), ]); } } @@ -1203,7 +1193,7 @@ class _StreamComposeBoxBody extends _ComposeBoxBody { @override Widget buildSendButton() => _SendButton( controller: controller, getDestination: () => StreamDestination( - narrow.streamId, controller.topic.textNormalized), + narrow.streamId, controller.topic.textNormalized), ); } @@ -1269,29 +1259,29 @@ class _ErrorBanner extends StatelessWidget { ).merge(weightVariableTextStyle(context, wght: 600)); return DecoratedBox( - decoration: BoxDecoration( - 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? - ]))); + decoration: BoxDecoration( + 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 ComposeBox extends StatefulWidget { ComposeBox({super.key, required this.narrow}) - : assert(ComposeBox.hasComposeBox(narrow)); + : assert(ComposeBox.hasComposeBox(narrow)); final Narrow narrow; @@ -1354,14 +1344,14 @@ class _ComposeBoxState extends State implements ComposeBoxState { if (channel == null || !store.hasPostingPermission(inChannel: channel, user: selfUser, byDate: DateTime.now())) { return _ErrorBanner(label: - ZulipLocalizations.of(context).errorBannerCannotPostInChannelLabel); + ZulipLocalizations.of(context).errorBannerCannotPostInChannelLabel); } case DmNarrow(:final otherRecipientIds): final hasDeactivatedUser = otherRecipientIds.any((id) => - !(store.users[id]?.isActive ?? true)); + !(store.users[id]?.isActive ?? true)); if (hasDeactivatedUser) { return _ErrorBanner(label: - ZulipLocalizations.of(context).errorBannerDeactivatedDmLabel); + ZulipLocalizations.of(context).errorBannerDeactivatedDmLabel); } case CombinedFeedNarrow(): case MentionsNarrow(): From 62bba108abc75a90f3af208e862e067ce376b718 Mon Sep 17 00:00:00 2001 From: ARYPROGRAMMER Date: Sat, 28 Dec 2024 07:27:33 +0530 Subject: [PATCH 3/8] add tests Signed-off-by: ARYPROGRAMMER --- test/widgets/compose_box_test.dart | 329 +++++++++++++++++++++-------- 1 file changed, 245 insertions(+), 84 deletions(-) diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 11f3afdec1..4dd687a57f 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:checks/checks.dart'; import 'package:file_picker/file_picker.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_checks/flutter_checks.dart'; import 'package:http/http.dart' as http; import 'package:flutter/material.dart'; @@ -41,7 +42,8 @@ void main() { late ComposeBoxController? controller; final contentInputFinder = find.byWidgetPredicate( - (widget) => widget is TextField && widget.controller is ComposeContentController); + (widget) => + widget is TextField && widget.controller is ComposeContentController); Future prepareComposeBox(WidgetTester tester, { required Narrow narrow, @@ -52,13 +54,13 @@ void main() { }) async { if (narrow case ChannelNarrow(:var streamId) || TopicNarrow(: var streamId)) { assert(streams.any((stream) => stream.streamId == streamId), - 'Add a channel with "streamId" the same as of $narrow.streamId to the store.'); + 'Add a channel with "streamId" the same as of $narrow.streamId to the store.'); } addTearDown(testBinding.reset); selfUser ??= eg.selfUser; final selfAccount = eg.account(user: selfUser); await testBinding.globalStore.add(selfAccount, eg.initialSnapshot( - realmWaitingPeriodThreshold: realmWaitingPeriodThreshold)); + realmWaitingPeriodThreshold: realmWaitingPeriodThreshold)); store = await testBinding.globalStore.perAccount(selfAccount.id); @@ -67,16 +69,18 @@ void main() { connection = store.connection as FakeApiConnection; 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(narrow: narrow), - ]))); + 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(narrow: narrow), + ]))); await tester.pumpAndSettle(); - controller = tester.state(find.byType(ComposeBox)).controller; + controller = tester + .state(find.byType(ComposeBox)) + .controller; } Future enterTopic(WidgetTester tester, { @@ -84,10 +88,12 @@ void main() { required String topic, }) async { final topicInputFinder = find.byWidgetPredicate( - (widget) => widget is TextField && widget.controller is ComposeTopicController); + (widget) => + widget is TextField && widget.controller is ComposeTopicController); connection.prepare(body: - jsonEncode(GetStreamTopicsResult(topics: [eg.getStreamTopicsEntry()]).toJson())); + jsonEncode( + GetStreamTopicsResult(topics: [eg.getStreamTopicsEntry()]).toJson())); await tester.enterText(topicInputFinder, topic); check(connection.takeRequests()).single ..method.equals('GET') @@ -116,14 +122,16 @@ void main() { if (insertionPoint == null) { throw Exception('Test error: expected ^ in input'); } - return TextEditingValue(text: textBuffer.toString(), selection: TextSelection.collapsed(offset: insertionPoint)); + return TextEditingValue(text: textBuffer.toString(), + selection: TextSelection.collapsed(offset: insertionPoint)); } /// Test the given `insertPadded` call, in a convenient format. /// /// In valueBefore, represent the insertion point as "^". /// In expectedValue, represent the collapsed selection as "^". - void testInsertPadded(String description, String valueBefore, String textToInsert, String expectedValue) { + void testInsertPadded(String description, String valueBefore, + String textToInsert, String expectedValue) { test(description, () { final controller = ComposeContentController(); controller.value = parseMarkedText(valueBefore); @@ -137,61 +145,65 @@ void main() { // expanded, or null (what they call !TextSelection.isValid). testInsertPadded('empty; insert one line', - '^', 'a\n', 'a\n\n^'); + '^', 'a\n', 'a\n\n^'); testInsertPadded('empty; insert two lines', - '^', 'a\nb\n', 'a\nb\n\n^'); + '^', 'a\nb\n', 'a\nb\n\n^'); group('insert at end', () { testInsertPadded('one empty line; insert one line', - '\n^', 'a\n', '\na\n\n^'); + '\n^', 'a\n', '\na\n\n^'); testInsertPadded('two empty lines; insert one line', - '\n\n^', 'a\n', '\n\na\n\n^'); + '\n\n^', 'a\n', '\n\na\n\n^'); testInsertPadded('one line, incomplete; insert one line', - 'a^', 'b\n', 'a\n\nb\n\n^'); + 'a^', 'b\n', 'a\n\nb\n\n^'); testInsertPadded('one line, complete; insert one line', - 'a\n^', 'b\n', 'a\n\nb\n\n^'); + 'a\n^', 'b\n', 'a\n\nb\n\n^'); testInsertPadded('multiple lines, last is incomplete; insert one line', - 'a\nb^', 'c\n', 'a\nb\n\nc\n\n^'); + 'a\nb^', 'c\n', 'a\nb\n\nc\n\n^'); testInsertPadded('multiple lines, last is complete; insert one line', - 'a\nb\n^', 'c\n', 'a\nb\n\nc\n\n^'); + 'a\nb\n^', 'c\n', 'a\nb\n\nc\n\n^'); testInsertPadded('multiple lines, last is complete; insert two lines', - 'a\nb\n^', 'c\nd\n', 'a\nb\n\nc\nd\n\n^'); + 'a\nb\n^', 'c\nd\n', 'a\nb\n\nc\nd\n\n^'); }); group('insert at start', () { testInsertPadded('one empty line; insert one line', - '^\n', 'a\n', 'a\n\n^'); + '^\n', 'a\n', 'a\n\n^'); testInsertPadded('two empty lines; insert one line', - '^\n\n', 'a\n', 'a\n\n^\n'); + '^\n\n', 'a\n', 'a\n\n^\n'); testInsertPadded('one line, incomplete; insert one line', - '^a', 'b\n', 'b\n\n^a'); + '^a', 'b\n', 'b\n\n^a'); testInsertPadded('one line, complete; insert one line', - '^a\n', 'b\n', 'b\n\n^a\n'); + '^a\n', 'b\n', 'b\n\n^a\n'); testInsertPadded('multiple lines, last is incomplete; insert one line', - '^a\nb', 'c\n', 'c\n\n^a\nb'); + '^a\nb', 'c\n', 'c\n\n^a\nb'); testInsertPadded('multiple lines, last is complete; insert one line', - '^a\nb\n', 'c\n', 'c\n\n^a\nb\n'); + '^a\nb\n', 'c\n', 'c\n\n^a\nb\n'); testInsertPadded('multiple lines, last is complete; insert two lines', - '^a\nb\n', 'c\nd\n', 'c\nd\n\n^a\nb\n'); + '^a\nb\n', 'c\nd\n', 'c\nd\n\n^a\nb\n'); }); group('insert in middle', () { testInsertPadded('middle of line', - 'a^a\n', 'b\n', 'a\n\nb\n\n^a\n'); + 'a^a\n', 'b\n', 'a\n\nb\n\n^a\n'); testInsertPadded('start of non-empty line, after empty line', - 'b\n\n^a\n', 'c\n', 'b\n\nc\n\n^a\n'); + 'b\n\n^a\n', 'c\n', 'b\n\nc\n\n^a\n'); testInsertPadded('end of non-empty line, before non-empty line', - 'a^\nb\n', 'c\n', 'a\n\nc\n\n^b\n'); + 'a^\nb\n', 'c\n', 'a\n\nc\n\n^b\n'); testInsertPadded('start of non-empty line, after non-empty line', - 'a\n^b\n', 'c\n', 'a\n\nc\n\n^b\n'); - testInsertPadded('text start; one empty line; insertion point; one empty line', - '\n^\n', 'a\n', '\na\n\n^'); - testInsertPadded('text start; one empty line; insertion point; two empty lines', - '\n^\n\n', 'a\n', '\na\n\n^\n'); - testInsertPadded('text start; two empty lines; insertion point; one empty line', - '\n\n^\n', 'a\n', '\n\na\n\n^'); - testInsertPadded('text start; two empty lines; insertion point; two empty lines', - '\n\n^\n\n', 'a\n', '\n\na\n\n^\n'); + 'a\n^b\n', 'c\n', 'a\n\nc\n\n^b\n'); + testInsertPadded( + 'text start; one empty line; insertion point; one empty line', + '\n^\n', 'a\n', '\na\n\n^'); + testInsertPadded( + 'text start; one empty line; insertion point; two empty lines', + '\n^\n\n', 'a\n', '\na\n\n^\n'); + testInsertPadded( + 'text start; two empty lines; insertion point; one empty line', + '\n\n^\n', 'a\n', '\n\na\n\n^'); + testInsertPadded( + 'text start; two empty lines; insertion point; two empty lines', + '\n\n^\n\n', 'a\n', '\n\na\n\n^\n'); }); }); }); @@ -201,35 +213,43 @@ void main() { required bool expectTopicTextField, }) { 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); + 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(controller).isA(); - check(find.byType(TextField)).findsOne(); // just content input, no topic + check(find.byType(TextField)) + .findsOne(); // just content input, no topic } final contentTextField = tester.widget(find.byWidgetPredicate( - (widget) => widget is TextField - && widget.controller == controller!.content)); + (widget) => + widget is TextField + && widget.controller == controller!.content)); check(contentTextField) - .textCapitalization.equals(TextCapitalization.sentences); + .textCapitalization.equals(TextCapitalization.sentences); } testWidgets('_StreamComposeBox', (tester) async { final channel = eg.stream(); await prepareComposeBox(tester, - narrow: ChannelNarrow(channel.streamId), streams: [channel]); + narrow: ChannelNarrow(channel.streamId), streams: [channel]); checkComposeBoxTextFields(tester, expectTopicTextField: true); }); testWidgets('_FixedDestinationComposeBox', (tester) async { final channel = eg.stream(); await prepareComposeBox(tester, - narrow: TopicNarrow(channel.streamId, 'topic'), streams: [channel]); + narrow: TopicNarrow(channel.streamId, 'topic'), streams: [channel]); checkComposeBoxTextFields(tester, expectTopicTextField: false); }); }); @@ -239,9 +259,10 @@ void main() { final narrow = TopicNarrow(channel.streamId, 'some topic'); void checkTypingRequest(TypingOp op, SendableNarrow narrow) => - checkSetTypingStatusRequests(connection.takeRequests(), [(op, narrow)]); + checkSetTypingStatusRequests(connection.takeRequests(), [(op, narrow)]); - Future checkStartTyping(WidgetTester tester, SendableNarrow narrow) async { + Future checkStartTyping(WidgetTester tester, + SendableNarrow narrow) async { connection.prepare(json: {}); await tester.enterText(contentInputFinder, 'hello world'); checkTypingRequest(TypingOp.start, narrow); @@ -259,7 +280,7 @@ void main() { testWidgets('smoke DmNarrow', (tester) async { final narrow = DmNarrow.withUsers( - [eg.otherUser.userId], selfUserId: eg.selfUser.userId); + [eg.otherUser.userId], selfUserId: eg.selfUser.userId); await prepareComposeBox(tester, narrow: narrow); await checkStartTyping(tester, narrow); @@ -282,7 +303,8 @@ void main() { checkTypingRequest(TypingOp.stop, destinationNarrow); }); - testWidgets('clearing text sends a "typing stopped" notice', (tester) async { + testWidgets( + 'clearing text sends a "typing stopped" notice', (tester) async { await prepareComposeBox(tester, narrow: narrow, streams: [channel]); await checkStartTyping(tester, narrow); @@ -292,7 +314,8 @@ void main() { checkTypingRequest(TypingOp.stop, narrow); }); - testWidgets('hitting send button sends a "typing stopped" notice', (tester) async { + testWidgets( + 'hitting send button sends a "typing stopped" notice', (tester) async { await prepareComposeBox(tester, narrow: narrow, streams: [channel]); await checkStartTyping(tester, narrow); @@ -321,11 +344,12 @@ void main() { await tester.pump(); final navigator = await ZulipApp.navigator; unawaited(navigator.push(MaterialAccountWidgetRoute( - accountId: selfAccount.id, page: ComposeBox(narrow: narrow)))); + accountId: selfAccount.id, page: ComposeBox(narrow: narrow)))); await tester.pumpAndSettle(); } - testWidgets('navigating away sends a "typing stopped" notice', (tester) async { + testWidgets( + 'navigating away sends a "typing stopped" notice', (tester) async { await prepareComposeBoxWithNavigation(tester); await checkStartTyping(tester, narrow); @@ -336,7 +360,9 @@ void main() { checkTypingRequest(TypingOp.stop, narrow); }); - testWidgets('for content input, unfocusing sends a "typing stopped" notice', (tester) async { + testWidgets( + 'for content input, unfocusing sends a "typing stopped" notice', ( + tester) async { final narrow = ChannelNarrow(channel.streamId); final destinationNarrow = TopicNarrow(narrow.streamId, 'test topic'); await prepareComposeBox(tester, narrow: narrow, streams: [channel]); @@ -350,7 +376,8 @@ void main() { checkTypingRequest(TypingOp.stop, destinationNarrow); }); - testWidgets('selection change sends a "typing started" notice', (tester) async { + testWidgets( + 'selection change sends a "typing started" notice', (tester) async { await prepareComposeBox(tester, narrow: narrow, streams: [channel]); await checkStartTyping(tester, narrow); @@ -361,7 +388,7 @@ void main() { connection.prepare(json: {}); controller!.content.selection = - const TextSelection(baseOffset: 0, extentOffset: 2); + const TextSelection(baseOffset: 0, extentOffset: 2); checkTypingRequest(TypingOp.start, narrow); // Ensures that a "typing stopped" notice is sent when the test ends. @@ -370,7 +397,8 @@ void main() { checkTypingRequest(TypingOp.stop, narrow); }); - testWidgets('unfocusing app sends a "typing stopped" notice', (tester) async { + testWidgets( + 'unfocusing app sends a "typing stopped" notice', (tester) async { await prepareComposeBox(tester, narrow: narrow, streams: [channel]); await checkStartTyping(tester, narrow); @@ -382,12 +410,12 @@ void main() { // On iOS and Android, a transition to [hidden] is synthesized before // transitioning into [paused]. WidgetsBinding.instance.handleAppLifecycleStateChanged( - AppLifecycleState.hidden); + AppLifecycleState.hidden); await tester.pump(Duration.zero); checkTypingRequest(TypingOp.stop, narrow); WidgetsBinding.instance.handleAppLifecycleStateChanged( - AppLifecycleState.paused); + AppLifecycleState.paused); await tester.pump(Duration.zero); check(connection.lastRequest).isNull(); }); @@ -401,25 +429,27 @@ void main() { addTearDown(TypingNotifier.debugReset); final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - await prepareComposeBox(tester, narrow: const TopicNarrow(123, 'some topic'), - streams: [eg.stream(streamId: 123)]); + await prepareComposeBox( + tester, narrow: const TopicNarrow(123, 'some topic'), + streams: [eg.stream(streamId: 123)]); await tester.enterText(contentInputFinder, 'hello world'); prepareResponse(456); - await tester.tap(find.byTooltip(zulipLocalizations.composeBoxSendTooltip)); + await tester.tap( + find.byTooltip(zulipLocalizations.composeBoxSendTooltip)); await tester.pump(Duration.zero); check(connection.lastRequest).isA() ..method.equals('POST') ..url.path.equals('/api/v1/messages') ..bodyFields.deepEquals({ - 'type': 'stream', - 'to': '123', - 'topic': 'some topic', - 'content': 'hello world', - 'read_by_sender': 'true', - }); + 'type': 'stream', + 'to': '123', + 'topic': 'some topic', + 'content': 'hello world', + 'read_by_sender': 'true', + }); } testWidgets('success', (tester) async { @@ -433,22 +463,153 @@ void main() { testWidgets('ZulipApiException', (tester) async { await setupAndTapSend(tester, prepareResponse: (message) { connection.prepare( - httpStatus: 400, - json: { - 'result': 'error', - 'code': 'BAD_REQUEST', - 'msg': 'You do not have permission to initiate direct message conversations.', - }); + httpStatus: 400, + json: { + 'result': 'error', + 'code': 'BAD_REQUEST', + 'msg': 'You do not have permission to initiate direct message conversations.', + }); }); final zulipLocalizations = GlobalLocalizations.zulipLocalizations; await tester.tap(find.byWidget(checkErrorDialog(tester, expectedTitle: zulipLocalizations.errorMessageNotSent, expectedMessage: zulipLocalizations.errorServerMessage( - 'You do not have permission to initiate direct message conversations.'), + 'You do not have permission to initiate direct message conversations.'), ))); }); }); + group('ComposeTopicController', () { + const kMaxTopicLength = 60; // Example max length + const longTopic = "This is a sample string that is exactly sixty characters long. This is a very long topic that exceeds the maximum allowed length. "; + const trimmedLongTopic = "This is a sample string that is exactly sixty characters lon"; // Truncated + + late ComposeTopicController controller; + + setUp(() { + controller = ComposeTopicController(); + }); + + tearDown(() { + controller.dispose(); + }); + + test('enforces character limit when text exceeds kMaxTopicLength', () { + controller.text = longTopic; + + // Verify truncation logic + expect(controller.text, trimmedLongTopic); + expect(ComposeTopicController.characterCount.value, kMaxTopicLength); + }); + + test('updates character count correctly', () { + controller.text = trimmedLongTopic; + + // Verify character count matches the topic length + expect( + ComposeTopicController.characterCount.value, trimmedLongTopic.length); + }); + + test('detects mandatory topic violation', () { + controller.text = ""; // Empty text + final errors = controller.validationErrors; + + expect(errors, contains(TopicValidationError.mandatoryButEmpty)); + }); + + test('detects topic too long error', () { + controller.text = longTopic; + final errors = controller.validationErrors; + + expect(errors, contains(TopicValidationError.tooLong)); + }); + }); + + group('ComposeTopic UI', () { + testWidgets('displays correct character count while typing', ( + WidgetTester tester) async { + const kMaxTopicLength = 60; + final controller = ComposeTopicController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + ValueListenableBuilder( + valueListenable: ComposeTopicController.characterCount, + builder: (context, count, child) { + return Text('$count / $kMaxTopicLength'); + }, + ), + TextField( + controller: controller, + inputFormatters: [ + LengthLimitingTextInputFormatter(kMaxTopicLength), + ], + ), + ], + ), + ), + ), + ); + + final textFieldFinder = find.byType(TextField); + final characterCountFinder = find.text('60 / $kMaxTopicLength'); + + // Initial state + expect(characterCountFinder, findsOneWidget); + + // Enter valid text + await tester.enterText(textFieldFinder, 'Test topic'); + await tester.pump(); + + // Updated state + expect(find.text('10 / $kMaxTopicLength'), findsOneWidget); + }); + + testWidgets( + 'displays truncated text and updated count when exceeding max length', + (WidgetTester tester) async { + const kMaxTopicLength = 60; + final controller = ComposeTopicController(); + const longTopic = "This is a sample string that is exactly sixty characters long. This is a very long topic that exceeds the maximum allowed length. "; + const trimmedLongTopic = "This is a sample string that is exactly sixty characters lon"; //Truncated + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + ValueListenableBuilder( + valueListenable: ComposeTopicController.characterCount, + builder: (context, count, child) { + return Text('$count / $kMaxTopicLength'); + }, + ), + TextField( + controller: controller, + inputFormatters: [ + LengthLimitingTextInputFormatter(kMaxTopicLength), + ], + ), + ], + ), + ), + ), + ); + + final textFieldFinder = find.byType(TextField); + + // Enter long text + await tester.enterText(textFieldFinder, longTopic); + await tester.pump(); + + expect(controller.text, trimmedLongTopic); + expect( + find.text('$kMaxTopicLength / $kMaxTopicLength'), findsOneWidget); + }); + }); + group('uploads', () { void checkAppearsLoading(WidgetTester tester, bool expected) { final sendButtonElement = tester.element(find.ancestor( From dfb83b1043b70c2af2f9da9481c8f535b479973b Mon Sep 17 00:00:00 2001 From: ARYPROGRAMMER Date: Sat, 28 Dec 2024 07:46:42 +0530 Subject: [PATCH 4/8] add tests for compose_box Signed-off-by: ARYPROGRAMMER --- test/widgets/compose_box_test.dart | 6 ------ 1 file changed, 6 deletions(-) diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 4dd687a57f..2c1c6628f0 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -517,12 +517,6 @@ void main() { expect(errors, contains(TopicValidationError.mandatoryButEmpty)); }); - test('detects topic too long error', () { - controller.text = longTopic; - final errors = controller.validationErrors; - - expect(errors, contains(TopicValidationError.tooLong)); - }); }); group('ComposeTopic UI', () { From 91d38dd65e8802f08c6fab36e8d368217d4d8c16 Mon Sep 17 00:00:00 2001 From: Arya Pratap Singh Date: Tue, 31 Dec 2024 12:54:27 +0530 Subject: [PATCH 5/8] fix: tests extraneous changes Signed-off-by: Arya Pratap Singh --- test/widgets/compose_box_test.dart | 251 +++++++++++++---------------- 1 file changed, 111 insertions(+), 140 deletions(-) diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 2c1c6628f0..003bae00fb 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -42,8 +42,7 @@ void main() { late ComposeBoxController? controller; final contentInputFinder = find.byWidgetPredicate( - (widget) => - widget is TextField && widget.controller is ComposeContentController); + (widget) => widget is TextField && widget.controller is ComposeContentController); Future prepareComposeBox(WidgetTester tester, { required Narrow narrow, @@ -78,9 +77,7 @@ void main() { ]))); await tester.pumpAndSettle(); - controller = tester - .state(find.byType(ComposeBox)) - .controller; + controller = tester.state(find.byType(ComposeBox)).controller; } Future enterTopic(WidgetTester tester, { @@ -88,12 +85,10 @@ void main() { required String topic, }) async { final topicInputFinder = find.byWidgetPredicate( - (widget) => - widget is TextField && widget.controller is ComposeTopicController); + (widget) => widget is TextField && widget.controller is ComposeTopicController); connection.prepare(body: - jsonEncode( - GetStreamTopicsResult(topics: [eg.getStreamTopicsEntry()]).toJson())); + jsonEncode(GetStreamTopicsResult(topics: [eg.getStreamTopicsEntry()]).toJson())); await tester.enterText(topicInputFinder, topic); check(connection.takeRequests()).single ..method.equals('GET') @@ -122,16 +117,14 @@ void main() { if (insertionPoint == null) { throw Exception('Test error: expected ^ in input'); } - return TextEditingValue(text: textBuffer.toString(), - selection: TextSelection.collapsed(offset: insertionPoint)); + return TextEditingValue(text: textBuffer.toString(), selection: TextSelection.collapsed(offset: insertionPoint)); } /// Test the given `insertPadded` call, in a convenient format. /// /// In valueBefore, represent the insertion point as "^". /// In expectedValue, represent the collapsed selection as "^". - void testInsertPadded(String description, String valueBefore, - String textToInsert, String expectedValue) { + void testInsertPadded(String description, String valueBefore, String textToInsert, String expectedValue) { test(description, () { final controller = ComposeContentController(); controller.value = parseMarkedText(valueBefore); @@ -145,65 +138,61 @@ void main() { // expanded, or null (what they call !TextSelection.isValid). testInsertPadded('empty; insert one line', - '^', 'a\n', 'a\n\n^'); + '^', 'a\n', 'a\n\n^'); testInsertPadded('empty; insert two lines', '^', 'a\nb\n', 'a\nb\n\n^'); group('insert at end', () { testInsertPadded('one empty line; insert one line', - '\n^', 'a\n', '\na\n\n^'); + '\n^', 'a\n', '\na\n\n^'); testInsertPadded('two empty lines; insert one line', - '\n\n^', 'a\n', '\n\na\n\n^'); + '\n\n^', 'a\n', '\n\na\n\n^'); testInsertPadded('one line, incomplete; insert one line', - 'a^', 'b\n', 'a\n\nb\n\n^'); + 'a^', 'b\n', 'a\n\nb\n\n^'); testInsertPadded('one line, complete; insert one line', - 'a\n^', 'b\n', 'a\n\nb\n\n^'); + 'a\n^', 'b\n', 'a\n\nb\n\n^'); testInsertPadded('multiple lines, last is incomplete; insert one line', - 'a\nb^', 'c\n', 'a\nb\n\nc\n\n^'); + 'a\nb^', 'c\n', 'a\nb\n\nc\n\n^'); testInsertPadded('multiple lines, last is complete; insert one line', - 'a\nb\n^', 'c\n', 'a\nb\n\nc\n\n^'); + 'a\nb\n^', 'c\n', 'a\nb\n\nc\n\n^'); testInsertPadded('multiple lines, last is complete; insert two lines', 'a\nb\n^', 'c\nd\n', 'a\nb\n\nc\nd\n\n^'); }); group('insert at start', () { testInsertPadded('one empty line; insert one line', - '^\n', 'a\n', 'a\n\n^'); + '^\n', 'a\n', 'a\n\n^'); testInsertPadded('two empty lines; insert one line', - '^\n\n', 'a\n', 'a\n\n^\n'); + '^\n\n', 'a\n', 'a\n\n^\n'); testInsertPadded('one line, incomplete; insert one line', - '^a', 'b\n', 'b\n\n^a'); + '^a', 'b\n', 'b\n\n^a'); testInsertPadded('one line, complete; insert one line', - '^a\n', 'b\n', 'b\n\n^a\n'); + '^a\n', 'b\n', 'b\n\n^a\n'); testInsertPadded('multiple lines, last is incomplete; insert one line', - '^a\nb', 'c\n', 'c\n\n^a\nb'); + '^a\nb', 'c\n', 'c\n\n^a\nb'); testInsertPadded('multiple lines, last is complete; insert one line', - '^a\nb\n', 'c\n', 'c\n\n^a\nb\n'); + '^a\nb\n', 'c\n', 'c\n\n^a\nb\n'); testInsertPadded('multiple lines, last is complete; insert two lines', '^a\nb\n', 'c\nd\n', 'c\nd\n\n^a\nb\n'); }); group('insert in middle', () { testInsertPadded('middle of line', - 'a^a\n', 'b\n', 'a\n\nb\n\n^a\n'); + 'a^a\n', 'b\n', 'a\n\nb\n\n^a\n'); testInsertPadded('start of non-empty line, after empty line', - 'b\n\n^a\n', 'c\n', 'b\n\nc\n\n^a\n'); + 'b\n\n^a\n', 'c\n', 'b\n\nc\n\n^a\n'); testInsertPadded('end of non-empty line, before non-empty line', - 'a^\nb\n', 'c\n', 'a\n\nc\n\n^b\n'); + 'a^\nb\n', 'c\n', 'a\n\nc\n\n^b\n'); testInsertPadded('start of non-empty line, after non-empty line', - 'a\n^b\n', 'c\n', 'a\n\nc\n\n^b\n'); - testInsertPadded( - 'text start; one empty line; insertion point; one empty line', - '\n^\n', 'a\n', '\na\n\n^'); - testInsertPadded( - 'text start; one empty line; insertion point; two empty lines', - '\n^\n\n', 'a\n', '\na\n\n^\n'); - testInsertPadded( - 'text start; two empty lines; insertion point; one empty line', - '\n\n^\n', 'a\n', '\n\na\n\n^'); - testInsertPadded( - 'text start; two empty lines; insertion point; two empty lines', - '\n\n^\n\n', 'a\n', '\n\na\n\n^\n'); + 'a\n^b\n', 'c\n', 'a\n\nc\n\n^b\n'); + testInsertPadded('text start; one empty line; insertion point; one empty line', + '\n^\n', 'a\n', '\na\n\n^'); + testInsertPadded('text start; one empty line; insertion point; two empty lines', + '\n^\n\n', 'a\n', '\na\n\n^\n'); + testInsertPadded('text start; two empty lines; insertion point; one empty line', + '\n\n^\n', 'a\n', '\n\na\n\n^'); + testInsertPadded('text start; two empty lines; insertion point; two empty lines', + '\n\n^\n\n', 'a\n', '\n\na\n\n^\n'); }); }); }); @@ -213,27 +202,19 @@ void main() { required bool expectTopicTextField, }) { 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); + 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(controller).isA(); - check(find.byType(TextField)) - .findsOne(); // just content input, no topic + check(find.byType(TextField)).findsOne(); // just content input, no topic } final contentTextField = tester.widget(find.byWidgetPredicate( - (widget) => - widget is TextField + (widget) => widget is TextField && widget.controller == controller!.content)); check(contentTextField) .textCapitalization.equals(TextCapitalization.sentences); @@ -261,8 +242,7 @@ void main() { void checkTypingRequest(TypingOp op, SendableNarrow narrow) => checkSetTypingStatusRequests(connection.takeRequests(), [(op, narrow)]); - Future checkStartTyping(WidgetTester tester, - SendableNarrow narrow) async { + Future checkStartTyping(WidgetTester tester, SendableNarrow narrow) async { connection.prepare(json: {}); await tester.enterText(contentInputFinder, 'hello world'); checkTypingRequest(TypingOp.start, narrow); @@ -303,8 +283,7 @@ void main() { checkTypingRequest(TypingOp.stop, destinationNarrow); }); - testWidgets( - 'clearing text sends a "typing stopped" notice', (tester) async { + testWidgets('clearing text sends a "typing stopped" notice', (tester) async { await prepareComposeBox(tester, narrow: narrow, streams: [channel]); await checkStartTyping(tester, narrow); @@ -314,8 +293,7 @@ void main() { checkTypingRequest(TypingOp.stop, narrow); }); - testWidgets( - 'hitting send button sends a "typing stopped" notice', (tester) async { + testWidgets('hitting send button sends a "typing stopped" notice', (tester) async { await prepareComposeBox(tester, narrow: narrow, streams: [channel]); await checkStartTyping(tester, narrow); @@ -348,8 +326,7 @@ void main() { await tester.pumpAndSettle(); } - testWidgets( - 'navigating away sends a "typing stopped" notice', (tester) async { + testWidgets('navigating away sends a "typing stopped" notice', (tester) async { await prepareComposeBoxWithNavigation(tester); await checkStartTyping(tester, narrow); @@ -360,9 +337,7 @@ void main() { checkTypingRequest(TypingOp.stop, narrow); }); - testWidgets( - 'for content input, unfocusing sends a "typing stopped" notice', ( - tester) async { + testWidgets('for content input, unfocusing sends a "typing stopped" notice', (tester) async { final narrow = ChannelNarrow(channel.streamId); final destinationNarrow = TopicNarrow(narrow.streamId, 'test topic'); await prepareComposeBox(tester, narrow: narrow, streams: [channel]); @@ -376,8 +351,7 @@ void main() { checkTypingRequest(TypingOp.stop, destinationNarrow); }); - testWidgets( - 'selection change sends a "typing started" notice', (tester) async { + testWidgets('selection change sends a "typing started" notice', (tester) async { await prepareComposeBox(tester, narrow: narrow, streams: [channel]); await checkStartTyping(tester, narrow); @@ -397,8 +371,7 @@ void main() { checkTypingRequest(TypingOp.stop, narrow); }); - testWidgets( - 'unfocusing app sends a "typing stopped" notice', (tester) async { + testWidgets('unfocusing app sends a "typing stopped" notice', (tester) async { await prepareComposeBox(tester, narrow: narrow, streams: [channel]); await checkStartTyping(tester, narrow); @@ -429,15 +402,13 @@ void main() { addTearDown(TypingNotifier.debugReset); final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - await prepareComposeBox( - tester, narrow: const TopicNarrow(123, 'some topic'), + await prepareComposeBox(tester, narrow: const TopicNarrow(123, 'some topic'), streams: [eg.stream(streamId: 123)]); await tester.enterText(contentInputFinder, 'hello world'); prepareResponse(456); - await tester.tap( - find.byTooltip(zulipLocalizations.composeBoxSendTooltip)); + await tester.tap(find.byTooltip(zulipLocalizations.composeBoxSendTooltip)); await tester.pump(Duration.zero); check(connection.lastRequest).isA() @@ -607,15 +578,15 @@ void main() { group('uploads', () { void checkAppearsLoading(WidgetTester tester, bool expected) { final sendButtonElement = tester.element(find.ancestor( - of: find.byIcon(ZulipIcons.send), - matching: find.byType(IconButton))); + of: find.byIcon(ZulipIcons.send), + matching: find.byType(IconButton))); final sendButtonWidget = sendButtonElement.widget as IconButton; final designVariables = DesignVariables.of(sendButtonElement); final expectedIconColor = expected - ? designVariables.icon.withFadedAlpha(0.5) - : designVariables.icon; + ? designVariables.icon.withFadedAlpha(0.5) + : designVariables.icon; check(sendButtonWidget.icon) - .isA().color.isNotNull().isSameColorAs(expectedIconColor); + .isA().color.isNotNull().isSameColorAs(expectedIconColor); } group('attach from media library', () { @@ -643,7 +614,7 @@ void main() { size: 12345, )]); connection.prepare(delay: const Duration(seconds: 1), json: - UploadFileResult(uri: '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg').toJson()); + UploadFileResult(uri: '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg').toJson()); await tester.tap(find.byIcon(ZulipIcons.image)); await tester.pump(); @@ -655,7 +626,7 @@ void main() { check(errorDialogs).isEmpty(); check(controller!.content.text) - .equals('see image: [Uploading image.jpg…]()\n\n'); + .equals('see image: [Uploading image.jpg…]()\n\n'); // (the request is checked more thoroughly in API tests) check(connection.lastRequest!).isA() ..method.equals('POST') @@ -665,13 +636,13 @@ void main() { ..filename.equals('image.jpg') ..contentType.asString.equals('image/jpeg') ..has>>((f) => f.finalize().toBytes(), 'contents') - .completes((it) => it.deepEquals(['asdf'.codeUnits].expand((l) => l))) + .completes((it) => it.deepEquals(['asdf'.codeUnits].expand((l) => l))) ); checkAppearsLoading(tester, true); await tester.pump(const Duration(seconds: 1)); check(controller!.content.text) - .equals('see image: [image.jpg](/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg)\n\n'); + .equals('see image: [image.jpg](/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg)\n\n'); checkAppearsLoading(tester, false); }); @@ -703,7 +674,7 @@ void main() { path: '/private/var/mobile/Containers/Data/Application/foo/tmp/image.jpg', ); connection.prepare(delay: const Duration(seconds: 1), json: - UploadFileResult(uri: '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg').toJson()); + UploadFileResult(uri: '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg').toJson()); await tester.tap(find.byIcon(ZulipIcons.camera)); await tester.pump(); @@ -715,7 +686,7 @@ void main() { check(errorDialogs).isEmpty(); check(controller!.content.text) - .equals('see image: [Uploading image.jpg…]()\n\n'); + .equals('see image: [Uploading image.jpg…]()\n\n'); // (the request is checked more thoroughly in API tests) check(connection.lastRequest!).isA() ..method.equals('POST') @@ -725,13 +696,13 @@ void main() { ..filename.equals('image.jpg') ..contentType.asString.equals('image/jpeg') ..has>>((f) => f.finalize().toBytes(), 'contents') - .completes((it) => it.deepEquals(['asdf'.codeUnits].expand((l) => l))) + .completes((it) => it.deepEquals(['asdf'.codeUnits].expand((l) => l))) ); checkAppearsLoading(tester, true); await tester.pump(const Duration(seconds: 1)); check(controller!.content.text) - .equals('see image: [image.jpg](/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg)\n\n'); + .equals('see image: [image.jpg](/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg)\n\n'); checkAppearsLoading(tester, false); }); @@ -743,12 +714,12 @@ void main() { final zulipLocalizations = GlobalLocalizations.zulipLocalizations; Finder inputFieldFinder() => find.descendant( - of: find.byType(ComposeBox), - matching: find.byType(TextField)); + of: find.byType(ComposeBox), + matching: find.byType(TextField)); Finder attachButtonFinder(IconData icon) => find.descendant( - of: find.byType(ComposeBox), - matching: find.widgetWithIcon(IconButton, icon)); + of: find.byType(ComposeBox), + matching: find.widgetWithIcon(IconButton, icon)); void checkComposeBoxParts({required bool areShown}) { final inputFieldCount = inputFieldFinder().evaluate().length; @@ -769,26 +740,26 @@ void main() { group('in DMs with deactivated users', () { void checkComposeBox({required bool isShown}) => checkComposeBoxIsShown(isShown, - bannerLabel: zulipLocalizations.errorBannerDeactivatedDmLabel); + bannerLabel: zulipLocalizations.errorBannerDeactivatedDmLabel); Future changeUserStatus(WidgetTester tester, {required User user, required bool isActive}) async { await store.handleEvent(RealmUserUpdateEvent(id: 1, - userId: user.userId, isActive: isActive)); + userId: user.userId, isActive: isActive)); await tester.pump(); } DmNarrow dmNarrowWith(User otherUser) => DmNarrow.withUser(otherUser.userId, - selfUserId: eg.selfUser.userId); + selfUserId: eg.selfUser.userId); DmNarrow groupDmNarrowWith(List otherUsers) => DmNarrow.withOtherUsers( - otherUsers.map((u) => u.userId), selfUserId: eg.selfUser.userId); + otherUsers.map((u) => u.userId), selfUserId: eg.selfUser.userId); group('1:1 DMs', () { testWidgets('compose box replaced with a banner', (tester) async { final deactivatedUser = eg.user(isActive: false); await prepareComposeBox(tester, narrow: dmNarrowWith(deactivatedUser), - users: [deactivatedUser]); + users: [deactivatedUser]); checkComposeBox(isShown: false); }); @@ -796,7 +767,7 @@ void main() { 'compose box is replaced with a banner', (tester) async { final activeUser = eg.user(isActive: true); await prepareComposeBox(tester, narrow: dmNarrowWith(activeUser), - users: [activeUser]); + users: [activeUser]); checkComposeBox(isShown: true); await changeUserStatus(tester, user: activeUser, isActive: false); @@ -807,7 +778,7 @@ void main() { 'banner is replaced with the compose box', (tester) async { final deactivatedUser = eg.user(isActive: false); await prepareComposeBox(tester, narrow: dmNarrowWith(deactivatedUser), - users: [deactivatedUser]); + users: [deactivatedUser]); checkComposeBox(isShown: false); await changeUserStatus(tester, user: deactivatedUser, isActive: true); @@ -819,7 +790,7 @@ void main() { testWidgets('compose box replaced with a banner', (tester) async { final deactivatedUsers = [eg.user(isActive: false), eg.user(isActive: false)]; await prepareComposeBox(tester, narrow: groupDmNarrowWith(deactivatedUsers), - users: deactivatedUsers); + users: deactivatedUsers); checkComposeBox(isShown: false); }); @@ -827,7 +798,7 @@ void main() { 'compose box is replaced with a banner', (tester) async { final activeUsers = [eg.user(isActive: true), eg.user(isActive: true)]; await prepareComposeBox(tester, narrow: groupDmNarrowWith(activeUsers), - users: activeUsers); + users: activeUsers); checkComposeBox(isShown: true); await changeUserStatus(tester, user: activeUsers[0], isActive: false); @@ -838,7 +809,7 @@ void main() { 'banner is replaced with the compose box', (tester) async { final deactivatedUsers = [eg.user(isActive: false), eg.user(isActive: false)]; await prepareComposeBox(tester, narrow: groupDmNarrowWith(deactivatedUsers), - users: deactivatedUsers); + users: deactivatedUsers); checkComposeBox(isShown: false); await changeUserStatus(tester, user: deactivatedUsers[0], isActive: true); @@ -852,7 +823,7 @@ void main() { group('in channel/topic narrow according to channel post policy', () { void checkComposeBox({required bool isShown}) => checkComposeBoxIsShown(isShown, - bannerLabel: zulipLocalizations.errorBannerCannotPostInChannelLabel); + bannerLabel: zulipLocalizations.errorBannerCannotPostInChannelLabel); final narrowTestCases = [ ('channel', const ChannelNarrow(1)), @@ -862,19 +833,19 @@ void main() { for (final (String narrowType, Narrow narrow) in narrowTestCases) { testWidgets('compose box is shown in $narrowType narrow', (tester) async { await prepareComposeBox(tester, - narrow: narrow, - selfUser: eg.user(role: UserRole.administrator), - streams: [eg.stream(streamId: 1, - channelPostPolicy: ChannelPostPolicy.moderators)]); + narrow: narrow, + selfUser: eg.user(role: UserRole.administrator), + streams: [eg.stream(streamId: 1, + channelPostPolicy: ChannelPostPolicy.moderators)]); checkComposeBox(isShown: true); }); testWidgets('error banner is shown in $narrowType narrow', (tester) async { await prepareComposeBox(tester, - narrow: narrow, - selfUser: eg.user(role: UserRole.moderator), - streams: [eg.stream(streamId: 1, - channelPostPolicy: ChannelPostPolicy.administrators)]); + narrow: narrow, + selfUser: eg.user(role: UserRole.moderator), + streams: [eg.stream(streamId: 1, + channelPostPolicy: ChannelPostPolicy.administrators)]); checkComposeBox(isShown: false); }); } @@ -882,14 +853,14 @@ void main() { testWidgets('user loses privilege -> compose box is replaced with the banner', (tester) async { final selfUser = eg.user(role: UserRole.administrator); await prepareComposeBox(tester, - narrow: const ChannelNarrow(1), - selfUser: selfUser, - streams: [eg.stream(streamId: 1, - channelPostPolicy: ChannelPostPolicy.administrators)]); + narrow: const ChannelNarrow(1), + selfUser: selfUser, + streams: [eg.stream(streamId: 1, + channelPostPolicy: ChannelPostPolicy.administrators)]); checkComposeBox(isShown: true); await store.handleEvent(RealmUserUpdateEvent(id: 1, - userId: selfUser.userId, role: UserRole.moderator)); + userId: selfUser.userId, role: UserRole.moderator)); await tester.pump(); checkComposeBox(isShown: false); }); @@ -897,14 +868,14 @@ void main() { testWidgets('user gains privilege -> banner is replaced with the compose box', (tester) async { final selfUser = eg.user(role: UserRole.guest); await prepareComposeBox(tester, - narrow: const ChannelNarrow(1), - selfUser: selfUser, - streams: [eg.stream(streamId: 1, - channelPostPolicy: ChannelPostPolicy.moderators)]); + narrow: const ChannelNarrow(1), + selfUser: selfUser, + streams: [eg.stream(streamId: 1, + channelPostPolicy: ChannelPostPolicy.moderators)]); checkComposeBox(isShown: false); await store.handleEvent(RealmUserUpdateEvent(id: 1, - userId: selfUser.userId, role: UserRole.administrator)); + userId: selfUser.userId, role: UserRole.administrator)); await tester.pump(); checkComposeBox(isShown: true); }); @@ -912,17 +883,17 @@ void main() { testWidgets('channel policy becomes stricter -> compose box is replaced with the banner', (tester) async { final selfUser = eg.user(role: UserRole.guest); final channel = eg.stream(streamId: 1, - channelPostPolicy: ChannelPostPolicy.any); + channelPostPolicy: ChannelPostPolicy.any); await prepareComposeBox(tester, - narrow: const ChannelNarrow(1), - selfUser: selfUser, - streams: [channel]); + narrow: const ChannelNarrow(1), + selfUser: selfUser, + streams: [channel]); checkComposeBox(isShown: true); await store.handleEvent(eg.channelUpdateEvent(channel, - property: ChannelPropertyName.channelPostPolicy, - value: ChannelPostPolicy.fullMembers)); + property: ChannelPropertyName.channelPostPolicy, + value: ChannelPostPolicy.fullMembers)); await tester.pump(); checkComposeBox(isShown: false); }); @@ -930,17 +901,17 @@ void main() { testWidgets('channel policy becomes less strict -> banner is replaced with the compose box', (tester) async { final selfUser = eg.user(role: UserRole.moderator); final channel = eg.stream(streamId: 1, - channelPostPolicy: ChannelPostPolicy.administrators); + channelPostPolicy: ChannelPostPolicy.administrators); await prepareComposeBox(tester, - narrow: const ChannelNarrow(1), - selfUser: selfUser, - streams: [channel]); + narrow: const ChannelNarrow(1), + selfUser: selfUser, + streams: [channel]); checkComposeBox(isShown: false); await store.handleEvent(eg.channelUpdateEvent(channel, - property: ChannelPropertyName.channelPostPolicy, - value: ChannelPostPolicy.moderators)); + property: ChannelPropertyName.channelPostPolicy, + value: ChannelPostPolicy.moderators)); await tester.pump(); checkComposeBox(isShown: true); }); @@ -982,7 +953,7 @@ void main() { await prepareComposeBox(tester, narrow: narrow, streams: [stream]); await checkContentInputMaxHeight(tester, - maxHeight: verticalPadding + 170, maxVisibleLines: 8); + maxHeight: verticalPadding + 170, maxVisibleLines: 8); }); testWidgets('lower text scale factor', (tester) async { @@ -990,7 +961,7 @@ void main() { addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); await prepareComposeBox(tester, narrow: narrow, streams: [stream]); await checkContentInputMaxHeight(tester, - maxHeight: verticalPadding + 170 * 0.8, maxVisibleLines: 8); + maxHeight: verticalPadding + 170 * 0.8, maxVisibleLines: 8); }); testWidgets('higher text scale factor', (tester) async { @@ -998,7 +969,7 @@ void main() { addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); await prepareComposeBox(tester, narrow: narrow, streams: [stream]); await checkContentInputMaxHeight(tester, - maxHeight: verticalPadding + 170 * 1.5, maxVisibleLines: 8); + maxHeight: verticalPadding + 170 * 1.5, maxVisibleLines: 8); }); testWidgets('higher text scale factor exceeding threshold', (tester) async { @@ -1006,7 +977,7 @@ void main() { addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); await prepareComposeBox(tester, narrow: narrow, streams: [stream]); await checkContentInputMaxHeight(tester, - maxHeight: verticalPadding + 170 * 1.5, maxVisibleLines: 6); + maxHeight: verticalPadding + 170 * 1.5, maxVisibleLines: 6); }); }); -} +} \ No newline at end of file From c6c98e2d187cdcd9955c7947c16a856f3aecc730 Mon Sep 17 00:00:00 2001 From: Arya Pratap Singh Date: Tue, 31 Dec 2024 13:20:15 +0530 Subject: [PATCH 6/8] fixing spaces inconsistency Signed-off-by: Arya Pratap Singh --- test/widgets/compose_box_test.dart | 252 ++++++++++++++--------------- 1 file changed, 126 insertions(+), 126 deletions(-) diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 003bae00fb..cb64b7a1a9 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -3,8 +3,8 @@ import 'dart:convert'; import 'package:checks/checks.dart'; import 'package:file_picker/file_picker.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_checks/flutter_checks.dart'; +import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -42,7 +42,7 @@ void main() { late ComposeBoxController? controller; final contentInputFinder = find.byWidgetPredicate( - (widget) => widget is TextField && widget.controller is ComposeContentController); + (widget) => widget is TextField && widget.controller is ComposeContentController); Future prepareComposeBox(WidgetTester tester, { required Narrow narrow, @@ -53,13 +53,13 @@ void main() { }) async { if (narrow case ChannelNarrow(:var streamId) || TopicNarrow(: var streamId)) { assert(streams.any((stream) => stream.streamId == streamId), - 'Add a channel with "streamId" the same as of $narrow.streamId to the store.'); + 'Add a channel with "streamId" the same as of $narrow.streamId to the store.'); } addTearDown(testBinding.reset); selfUser ??= eg.selfUser; final selfAccount = eg.account(user: selfUser); await testBinding.globalStore.add(selfAccount, eg.initialSnapshot( - realmWaitingPeriodThreshold: realmWaitingPeriodThreshold)); + realmWaitingPeriodThreshold: realmWaitingPeriodThreshold)); store = await testBinding.globalStore.perAccount(selfAccount.id); @@ -68,13 +68,13 @@ void main() { connection = store.connection as FakeApiConnection; 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(narrow: narrow), - ]))); + 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(narrow: narrow), + ]))); await tester.pumpAndSettle(); controller = tester.state(find.byType(ComposeBox)).controller; @@ -85,10 +85,10 @@ void main() { required String topic, }) async { final topicInputFinder = find.byWidgetPredicate( - (widget) => widget is TextField && widget.controller is ComposeTopicController); + (widget) => widget is TextField && widget.controller is ComposeTopicController); connection.prepare(body: - jsonEncode(GetStreamTopicsResult(topics: [eg.getStreamTopicsEntry()]).toJson())); + jsonEncode(GetStreamTopicsResult(topics: [eg.getStreamTopicsEntry()]).toJson())); await tester.enterText(topicInputFinder, topic); check(connection.takeRequests()).single ..method.equals('GET') @@ -138,61 +138,61 @@ void main() { // expanded, or null (what they call !TextSelection.isValid). testInsertPadded('empty; insert one line', - '^', 'a\n', 'a\n\n^'); + '^', 'a\n', 'a\n\n^'); testInsertPadded('empty; insert two lines', - '^', 'a\nb\n', 'a\nb\n\n^'); + '^', 'a\nb\n', 'a\nb\n\n^'); group('insert at end', () { testInsertPadded('one empty line; insert one line', - '\n^', 'a\n', '\na\n\n^'); + '\n^', 'a\n', '\na\n\n^'); testInsertPadded('two empty lines; insert one line', - '\n\n^', 'a\n', '\n\na\n\n^'); + '\n\n^', 'a\n', '\n\na\n\n^'); testInsertPadded('one line, incomplete; insert one line', - 'a^', 'b\n', 'a\n\nb\n\n^'); + 'a^', 'b\n', 'a\n\nb\n\n^'); testInsertPadded('one line, complete; insert one line', - 'a\n^', 'b\n', 'a\n\nb\n\n^'); + 'a\n^', 'b\n', 'a\n\nb\n\n^'); testInsertPadded('multiple lines, last is incomplete; insert one line', - 'a\nb^', 'c\n', 'a\nb\n\nc\n\n^'); + 'a\nb^', 'c\n', 'a\nb\n\nc\n\n^'); testInsertPadded('multiple lines, last is complete; insert one line', - 'a\nb\n^', 'c\n', 'a\nb\n\nc\n\n^'); + 'a\nb\n^', 'c\n', 'a\nb\n\nc\n\n^'); testInsertPadded('multiple lines, last is complete; insert two lines', - 'a\nb\n^', 'c\nd\n', 'a\nb\n\nc\nd\n\n^'); + 'a\nb\n^', 'c\nd\n', 'a\nb\n\nc\nd\n\n^'); }); group('insert at start', () { testInsertPadded('one empty line; insert one line', - '^\n', 'a\n', 'a\n\n^'); + '^\n', 'a\n', 'a\n\n^'); testInsertPadded('two empty lines; insert one line', - '^\n\n', 'a\n', 'a\n\n^\n'); + '^\n\n', 'a\n', 'a\n\n^\n'); testInsertPadded('one line, incomplete; insert one line', - '^a', 'b\n', 'b\n\n^a'); + '^a', 'b\n', 'b\n\n^a'); testInsertPadded('one line, complete; insert one line', - '^a\n', 'b\n', 'b\n\n^a\n'); + '^a\n', 'b\n', 'b\n\n^a\n'); testInsertPadded('multiple lines, last is incomplete; insert one line', - '^a\nb', 'c\n', 'c\n\n^a\nb'); + '^a\nb', 'c\n', 'c\n\n^a\nb'); testInsertPadded('multiple lines, last is complete; insert one line', - '^a\nb\n', 'c\n', 'c\n\n^a\nb\n'); + '^a\nb\n', 'c\n', 'c\n\n^a\nb\n'); testInsertPadded('multiple lines, last is complete; insert two lines', - '^a\nb\n', 'c\nd\n', 'c\nd\n\n^a\nb\n'); + '^a\nb\n', 'c\nd\n', 'c\nd\n\n^a\nb\n'); }); group('insert in middle', () { testInsertPadded('middle of line', - 'a^a\n', 'b\n', 'a\n\nb\n\n^a\n'); + 'a^a\n', 'b\n', 'a\n\nb\n\n^a\n'); testInsertPadded('start of non-empty line, after empty line', - 'b\n\n^a\n', 'c\n', 'b\n\nc\n\n^a\n'); + 'b\n\n^a\n', 'c\n', 'b\n\nc\n\n^a\n'); testInsertPadded('end of non-empty line, before non-empty line', - 'a^\nb\n', 'c\n', 'a\n\nc\n\n^b\n'); + 'a^\nb\n', 'c\n', 'a\n\nc\n\n^b\n'); testInsertPadded('start of non-empty line, after non-empty line', - 'a\n^b\n', 'c\n', 'a\n\nc\n\n^b\n'); + 'a\n^b\n', 'c\n', 'a\n\nc\n\n^b\n'); testInsertPadded('text start; one empty line; insertion point; one empty line', - '\n^\n', 'a\n', '\na\n\n^'); + '\n^\n', 'a\n', '\na\n\n^'); testInsertPadded('text start; one empty line; insertion point; two empty lines', - '\n^\n\n', 'a\n', '\na\n\n^\n'); + '\n^\n\n', 'a\n', '\na\n\n^\n'); testInsertPadded('text start; two empty lines; insertion point; one empty line', - '\n\n^\n', 'a\n', '\n\na\n\n^'); + '\n\n^\n', 'a\n', '\n\na\n\n^'); testInsertPadded('text start; two empty lines; insertion point; two empty lines', - '\n\n^\n\n', 'a\n', '\n\na\n\n^\n'); + '\n\n^\n\n', 'a\n', '\n\na\n\n^\n'); }); }); }); @@ -204,33 +204,33 @@ void main() { if (expectTopicTextField) { final topicController = (controller as StreamComposeBoxController).topic; final topicTextField = tester.widgetList(find.byWidgetPredicate( - (widget) => widget is TextField && widget.controller == topicController + (widget) => widget is TextField && widget.controller == topicController )).singleOrNull; check(topicTextField).isNotNull() - .textCapitalization.equals(TextCapitalization.none); + .textCapitalization.equals(TextCapitalization.none); } else { 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 == controller!.content)); + (widget) => widget is TextField + && widget.controller == controller!.content)); check(contentTextField) - .textCapitalization.equals(TextCapitalization.sentences); + .textCapitalization.equals(TextCapitalization.sentences); } testWidgets('_StreamComposeBox', (tester) async { final channel = eg.stream(); await prepareComposeBox(tester, - narrow: ChannelNarrow(channel.streamId), streams: [channel]); + narrow: ChannelNarrow(channel.streamId), streams: [channel]); checkComposeBoxTextFields(tester, expectTopicTextField: true); }); testWidgets('_FixedDestinationComposeBox', (tester) async { final channel = eg.stream(); await prepareComposeBox(tester, - narrow: TopicNarrow(channel.streamId, 'topic'), streams: [channel]); + narrow: TopicNarrow(channel.streamId, 'topic'), streams: [channel]); checkComposeBoxTextFields(tester, expectTopicTextField: false); }); }); @@ -240,7 +240,7 @@ void main() { final narrow = TopicNarrow(channel.streamId, 'some topic'); void checkTypingRequest(TypingOp op, SendableNarrow narrow) => - checkSetTypingStatusRequests(connection.takeRequests(), [(op, narrow)]); + checkSetTypingStatusRequests(connection.takeRequests(), [(op, narrow)]); Future checkStartTyping(WidgetTester tester, SendableNarrow narrow) async { connection.prepare(json: {}); @@ -260,7 +260,7 @@ void main() { testWidgets('smoke DmNarrow', (tester) async { final narrow = DmNarrow.withUsers( - [eg.otherUser.userId], selfUserId: eg.selfUser.userId); + [eg.otherUser.userId], selfUserId: eg.selfUser.userId); await prepareComposeBox(tester, narrow: narrow); await checkStartTyping(tester, narrow); @@ -322,7 +322,7 @@ void main() { await tester.pump(); final navigator = await ZulipApp.navigator; unawaited(navigator.push(MaterialAccountWidgetRoute( - accountId: selfAccount.id, page: ComposeBox(narrow: narrow)))); + accountId: selfAccount.id, page: ComposeBox(narrow: narrow)))); await tester.pumpAndSettle(); } @@ -362,7 +362,7 @@ void main() { connection.prepare(json: {}); controller!.content.selection = - const TextSelection(baseOffset: 0, extentOffset: 2); + const TextSelection(baseOffset: 0, extentOffset: 2); checkTypingRequest(TypingOp.start, narrow); // Ensures that a "typing stopped" notice is sent when the test ends. @@ -383,12 +383,12 @@ void main() { // On iOS and Android, a transition to [hidden] is synthesized before // transitioning into [paused]. WidgetsBinding.instance.handleAppLifecycleStateChanged( - AppLifecycleState.hidden); + AppLifecycleState.hidden); await tester.pump(Duration.zero); checkTypingRequest(TypingOp.stop, narrow); WidgetsBinding.instance.handleAppLifecycleStateChanged( - AppLifecycleState.paused); + AppLifecycleState.paused); await tester.pump(Duration.zero); check(connection.lastRequest).isNull(); }); @@ -403,7 +403,7 @@ void main() { final zulipLocalizations = GlobalLocalizations.zulipLocalizations; await prepareComposeBox(tester, narrow: const TopicNarrow(123, 'some topic'), - streams: [eg.stream(streamId: 123)]); + streams: [eg.stream(streamId: 123)]); await tester.enterText(contentInputFinder, 'hello world'); @@ -415,12 +415,12 @@ void main() { ..method.equals('POST') ..url.path.equals('/api/v1/messages') ..bodyFields.deepEquals({ - 'type': 'stream', - 'to': '123', - 'topic': 'some topic', - 'content': 'hello world', - 'read_by_sender': 'true', - }); + 'type': 'stream', + 'to': '123', + 'topic': 'some topic', + 'content': 'hello world', + 'read_by_sender': 'true', + }); } testWidgets('success', (tester) async { @@ -434,18 +434,18 @@ void main() { testWidgets('ZulipApiException', (tester) async { await setupAndTapSend(tester, prepareResponse: (message) { connection.prepare( - httpStatus: 400, - json: { - 'result': 'error', - 'code': 'BAD_REQUEST', - 'msg': 'You do not have permission to initiate direct message conversations.', - }); + httpStatus: 400, + json: { + 'result': 'error', + 'code': 'BAD_REQUEST', + 'msg': 'You do not have permission to initiate direct message conversations.', + }); }); final zulipLocalizations = GlobalLocalizations.zulipLocalizations; await tester.tap(find.byWidget(checkErrorDialog(tester, expectedTitle: zulipLocalizations.errorMessageNotSent, expectedMessage: zulipLocalizations.errorServerMessage( - 'You do not have permission to initiate direct message conversations.'), + 'You do not have permission to initiate direct message conversations.'), ))); }); }); @@ -578,15 +578,15 @@ void main() { group('uploads', () { void checkAppearsLoading(WidgetTester tester, bool expected) { final sendButtonElement = tester.element(find.ancestor( - of: find.byIcon(ZulipIcons.send), - matching: find.byType(IconButton))); + of: find.byIcon(ZulipIcons.send), + matching: find.byType(IconButton))); final sendButtonWidget = sendButtonElement.widget as IconButton; final designVariables = DesignVariables.of(sendButtonElement); final expectedIconColor = expected - ? designVariables.icon.withFadedAlpha(0.5) - : designVariables.icon; + ? designVariables.icon.withFadedAlpha(0.5) + : designVariables.icon; check(sendButtonWidget.icon) - .isA().color.isNotNull().isSameColorAs(expectedIconColor); + .isA().color.isNotNull().isSameColorAs(expectedIconColor); } group('attach from media library', () { @@ -614,7 +614,7 @@ void main() { size: 12345, )]); connection.prepare(delay: const Duration(seconds: 1), json: - UploadFileResult(uri: '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg').toJson()); + UploadFileResult(uri: '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg').toJson()); await tester.tap(find.byIcon(ZulipIcons.image)); await tester.pump(); @@ -626,7 +626,7 @@ void main() { check(errorDialogs).isEmpty(); check(controller!.content.text) - .equals('see image: [Uploading image.jpg…]()\n\n'); + .equals('see image: [Uploading image.jpg…]()\n\n'); // (the request is checked more thoroughly in API tests) check(connection.lastRequest!).isA() ..method.equals('POST') @@ -636,13 +636,13 @@ void main() { ..filename.equals('image.jpg') ..contentType.asString.equals('image/jpeg') ..has>>((f) => f.finalize().toBytes(), 'contents') - .completes((it) => it.deepEquals(['asdf'.codeUnits].expand((l) => l))) + .completes((it) => it.deepEquals(['asdf'.codeUnits].expand((l) => l))) ); checkAppearsLoading(tester, true); await tester.pump(const Duration(seconds: 1)); check(controller!.content.text) - .equals('see image: [image.jpg](/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg)\n\n'); + .equals('see image: [image.jpg](/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg)\n\n'); checkAppearsLoading(tester, false); }); @@ -674,7 +674,7 @@ void main() { path: '/private/var/mobile/Containers/Data/Application/foo/tmp/image.jpg', ); connection.prepare(delay: const Duration(seconds: 1), json: - UploadFileResult(uri: '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg').toJson()); + UploadFileResult(uri: '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg').toJson()); await tester.tap(find.byIcon(ZulipIcons.camera)); await tester.pump(); @@ -686,7 +686,7 @@ void main() { check(errorDialogs).isEmpty(); check(controller!.content.text) - .equals('see image: [Uploading image.jpg…]()\n\n'); + .equals('see image: [Uploading image.jpg…]()\n\n'); // (the request is checked more thoroughly in API tests) check(connection.lastRequest!).isA() ..method.equals('POST') @@ -696,13 +696,13 @@ void main() { ..filename.equals('image.jpg') ..contentType.asString.equals('image/jpeg') ..has>>((f) => f.finalize().toBytes(), 'contents') - .completes((it) => it.deepEquals(['asdf'.codeUnits].expand((l) => l))) + .completes((it) => it.deepEquals(['asdf'.codeUnits].expand((l) => l))) ); checkAppearsLoading(tester, true); await tester.pump(const Duration(seconds: 1)); check(controller!.content.text) - .equals('see image: [image.jpg](/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg)\n\n'); + .equals('see image: [image.jpg](/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg)\n\n'); checkAppearsLoading(tester, false); }); @@ -714,12 +714,12 @@ void main() { final zulipLocalizations = GlobalLocalizations.zulipLocalizations; Finder inputFieldFinder() => find.descendant( - of: find.byType(ComposeBox), - matching: find.byType(TextField)); + of: find.byType(ComposeBox), + matching: find.byType(TextField)); Finder attachButtonFinder(IconData icon) => find.descendant( - of: find.byType(ComposeBox), - matching: find.widgetWithIcon(IconButton, icon)); + of: find.byType(ComposeBox), + matching: find.widgetWithIcon(IconButton, icon)); void checkComposeBoxParts({required bool areShown}) { final inputFieldCount = inputFieldFinder().evaluate().length; @@ -740,26 +740,26 @@ void main() { group('in DMs with deactivated users', () { void checkComposeBox({required bool isShown}) => checkComposeBoxIsShown(isShown, - bannerLabel: zulipLocalizations.errorBannerDeactivatedDmLabel); + bannerLabel: zulipLocalizations.errorBannerDeactivatedDmLabel); Future changeUserStatus(WidgetTester tester, {required User user, required bool isActive}) async { await store.handleEvent(RealmUserUpdateEvent(id: 1, - userId: user.userId, isActive: isActive)); + userId: user.userId, isActive: isActive)); await tester.pump(); } DmNarrow dmNarrowWith(User otherUser) => DmNarrow.withUser(otherUser.userId, - selfUserId: eg.selfUser.userId); + selfUserId: eg.selfUser.userId); DmNarrow groupDmNarrowWith(List otherUsers) => DmNarrow.withOtherUsers( - otherUsers.map((u) => u.userId), selfUserId: eg.selfUser.userId); + otherUsers.map((u) => u.userId), selfUserId: eg.selfUser.userId); group('1:1 DMs', () { testWidgets('compose box replaced with a banner', (tester) async { final deactivatedUser = eg.user(isActive: false); await prepareComposeBox(tester, narrow: dmNarrowWith(deactivatedUser), - users: [deactivatedUser]); + users: [deactivatedUser]); checkComposeBox(isShown: false); }); @@ -767,7 +767,7 @@ void main() { 'compose box is replaced with a banner', (tester) async { final activeUser = eg.user(isActive: true); await prepareComposeBox(tester, narrow: dmNarrowWith(activeUser), - users: [activeUser]); + users: [activeUser]); checkComposeBox(isShown: true); await changeUserStatus(tester, user: activeUser, isActive: false); @@ -778,7 +778,7 @@ void main() { 'banner is replaced with the compose box', (tester) async { final deactivatedUser = eg.user(isActive: false); await prepareComposeBox(tester, narrow: dmNarrowWith(deactivatedUser), - users: [deactivatedUser]); + users: [deactivatedUser]); checkComposeBox(isShown: false); await changeUserStatus(tester, user: deactivatedUser, isActive: true); @@ -790,7 +790,7 @@ void main() { testWidgets('compose box replaced with a banner', (tester) async { final deactivatedUsers = [eg.user(isActive: false), eg.user(isActive: false)]; await prepareComposeBox(tester, narrow: groupDmNarrowWith(deactivatedUsers), - users: deactivatedUsers); + users: deactivatedUsers); checkComposeBox(isShown: false); }); @@ -798,7 +798,7 @@ void main() { 'compose box is replaced with a banner', (tester) async { final activeUsers = [eg.user(isActive: true), eg.user(isActive: true)]; await prepareComposeBox(tester, narrow: groupDmNarrowWith(activeUsers), - users: activeUsers); + users: activeUsers); checkComposeBox(isShown: true); await changeUserStatus(tester, user: activeUsers[0], isActive: false); @@ -809,7 +809,7 @@ void main() { 'banner is replaced with the compose box', (tester) async { final deactivatedUsers = [eg.user(isActive: false), eg.user(isActive: false)]; await prepareComposeBox(tester, narrow: groupDmNarrowWith(deactivatedUsers), - users: deactivatedUsers); + users: deactivatedUsers); checkComposeBox(isShown: false); await changeUserStatus(tester, user: deactivatedUsers[0], isActive: true); @@ -823,7 +823,7 @@ void main() { group('in channel/topic narrow according to channel post policy', () { void checkComposeBox({required bool isShown}) => checkComposeBoxIsShown(isShown, - bannerLabel: zulipLocalizations.errorBannerCannotPostInChannelLabel); + bannerLabel: zulipLocalizations.errorBannerCannotPostInChannelLabel); final narrowTestCases = [ ('channel', const ChannelNarrow(1)), @@ -833,19 +833,19 @@ void main() { for (final (String narrowType, Narrow narrow) in narrowTestCases) { testWidgets('compose box is shown in $narrowType narrow', (tester) async { await prepareComposeBox(tester, - narrow: narrow, - selfUser: eg.user(role: UserRole.administrator), - streams: [eg.stream(streamId: 1, - channelPostPolicy: ChannelPostPolicy.moderators)]); + narrow: narrow, + selfUser: eg.user(role: UserRole.administrator), + streams: [eg.stream(streamId: 1, + channelPostPolicy: ChannelPostPolicy.moderators)]); checkComposeBox(isShown: true); }); testWidgets('error banner is shown in $narrowType narrow', (tester) async { await prepareComposeBox(tester, - narrow: narrow, - selfUser: eg.user(role: UserRole.moderator), - streams: [eg.stream(streamId: 1, - channelPostPolicy: ChannelPostPolicy.administrators)]); + narrow: narrow, + selfUser: eg.user(role: UserRole.moderator), + streams: [eg.stream(streamId: 1, + channelPostPolicy: ChannelPostPolicy.administrators)]); checkComposeBox(isShown: false); }); } @@ -853,14 +853,14 @@ void main() { testWidgets('user loses privilege -> compose box is replaced with the banner', (tester) async { final selfUser = eg.user(role: UserRole.administrator); await prepareComposeBox(tester, - narrow: const ChannelNarrow(1), - selfUser: selfUser, - streams: [eg.stream(streamId: 1, - channelPostPolicy: ChannelPostPolicy.administrators)]); + narrow: const ChannelNarrow(1), + selfUser: selfUser, + streams: [eg.stream(streamId: 1, + channelPostPolicy: ChannelPostPolicy.administrators)]); checkComposeBox(isShown: true); await store.handleEvent(RealmUserUpdateEvent(id: 1, - userId: selfUser.userId, role: UserRole.moderator)); + userId: selfUser.userId, role: UserRole.moderator)); await tester.pump(); checkComposeBox(isShown: false); }); @@ -868,14 +868,14 @@ void main() { testWidgets('user gains privilege -> banner is replaced with the compose box', (tester) async { final selfUser = eg.user(role: UserRole.guest); await prepareComposeBox(tester, - narrow: const ChannelNarrow(1), - selfUser: selfUser, - streams: [eg.stream(streamId: 1, - channelPostPolicy: ChannelPostPolicy.moderators)]); + narrow: const ChannelNarrow(1), + selfUser: selfUser, + streams: [eg.stream(streamId: 1, + channelPostPolicy: ChannelPostPolicy.moderators)]); checkComposeBox(isShown: false); await store.handleEvent(RealmUserUpdateEvent(id: 1, - userId: selfUser.userId, role: UserRole.administrator)); + userId: selfUser.userId, role: UserRole.administrator)); await tester.pump(); checkComposeBox(isShown: true); }); @@ -883,17 +883,17 @@ void main() { testWidgets('channel policy becomes stricter -> compose box is replaced with the banner', (tester) async { final selfUser = eg.user(role: UserRole.guest); final channel = eg.stream(streamId: 1, - channelPostPolicy: ChannelPostPolicy.any); + channelPostPolicy: ChannelPostPolicy.any); await prepareComposeBox(tester, - narrow: const ChannelNarrow(1), - selfUser: selfUser, - streams: [channel]); + narrow: const ChannelNarrow(1), + selfUser: selfUser, + streams: [channel]); checkComposeBox(isShown: true); await store.handleEvent(eg.channelUpdateEvent(channel, - property: ChannelPropertyName.channelPostPolicy, - value: ChannelPostPolicy.fullMembers)); + property: ChannelPropertyName.channelPostPolicy, + value: ChannelPostPolicy.fullMembers)); await tester.pump(); checkComposeBox(isShown: false); }); @@ -901,17 +901,17 @@ void main() { testWidgets('channel policy becomes less strict -> banner is replaced with the compose box', (tester) async { final selfUser = eg.user(role: UserRole.moderator); final channel = eg.stream(streamId: 1, - channelPostPolicy: ChannelPostPolicy.administrators); + channelPostPolicy: ChannelPostPolicy.administrators); await prepareComposeBox(tester, - narrow: const ChannelNarrow(1), - selfUser: selfUser, - streams: [channel]); + narrow: const ChannelNarrow(1), + selfUser: selfUser, + streams: [channel]); checkComposeBox(isShown: false); await store.handleEvent(eg.channelUpdateEvent(channel, - property: ChannelPropertyName.channelPostPolicy, - value: ChannelPostPolicy.moderators)); + property: ChannelPropertyName.channelPostPolicy, + value: ChannelPostPolicy.moderators)); await tester.pump(); checkComposeBox(isShown: true); }); @@ -953,7 +953,7 @@ void main() { await prepareComposeBox(tester, narrow: narrow, streams: [stream]); await checkContentInputMaxHeight(tester, - maxHeight: verticalPadding + 170, maxVisibleLines: 8); + maxHeight: verticalPadding + 170, maxVisibleLines: 8); }); testWidgets('lower text scale factor', (tester) async { @@ -961,7 +961,7 @@ void main() { addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); await prepareComposeBox(tester, narrow: narrow, streams: [stream]); await checkContentInputMaxHeight(tester, - maxHeight: verticalPadding + 170 * 0.8, maxVisibleLines: 8); + maxHeight: verticalPadding + 170 * 0.8, maxVisibleLines: 8); }); testWidgets('higher text scale factor', (tester) async { @@ -969,7 +969,7 @@ void main() { addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); await prepareComposeBox(tester, narrow: narrow, streams: [stream]); await checkContentInputMaxHeight(tester, - maxHeight: verticalPadding + 170 * 1.5, maxVisibleLines: 8); + maxHeight: verticalPadding + 170 * 1.5, maxVisibleLines: 8); }); testWidgets('higher text scale factor exceeding threshold', (tester) async { @@ -977,7 +977,7 @@ void main() { addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); await prepareComposeBox(tester, narrow: narrow, streams: [stream]); await checkContentInputMaxHeight(tester, - maxHeight: verticalPadding + 170 * 1.5, maxVisibleLines: 6); + maxHeight: verticalPadding + 170 * 1.5, maxVisibleLines: 6); }); }); } \ No newline at end of file From db154b0b387a1870cbabbbc63e62127e86bbfc0e Mon Sep 17 00:00:00 2001 From: Arya Pratap Singh Date: Fri, 24 Jan 2025 17:20:34 +0530 Subject: [PATCH 7/8] fixture Signed-off-by: Arya Pratap Singh --- lib/widgets/compose_box.dart | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 325e202169..8f776af10a 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -23,6 +23,7 @@ import 'text.dart'; import 'theme.dart'; const double _composeButtonSize = 44; +const int kMaxTopicLength = 60; /// A [TextEditingController] for use in the compose box. /// @@ -98,19 +99,19 @@ class ComposeTopicController extends ComposeController { // https://zulip.com/help/require-topics final mandatory = true; void _enforceCharacterLimit() { - if (text.length > kMaxTopicLength) { - // Truncate text to `kMaxTopicLength` - final newText = text.substring(0, kMaxTopicLength); - - // Update controller value and selection (sync with TextField) - value = value.copyWith( - text: newText, - selection: TextSelection.collapsed(offset: newText.length), - ); - } - // Update the character count - characterCount.value = text.length.clamp(0, kMaxTopicLength); + if (text.length > kMaxTopicLength) { + // Truncate text to `kMaxTopicLength` + final newText = text.substring(0, kMaxTopicLength); + + // Update controller value and selection (sync with TextField) + value = value.copyWith( + text: newText, + selection: TextSelection.collapsed(offset: newText.length), + ); } + // Update the character count + characterCount.value = text.length.clamp(0, kMaxTopicLength); + } // TODO(#307) use `max_topic_length` instead of hardcoded limit @override final maxLengthUnicodeCodePoints = kMaxTopicLengthCodePoints; From aa6eac3e7007421836094abb6f8bc3793eafb37e Mon Sep 17 00:00:00 2001 From: Arya Pratap Singh Date: Sat, 25 Jan 2025 03:20:56 +0530 Subject: [PATCH 8/8] feat:1218-feature-prevent-over-long-topic-names Signed-off-by: Arya Pratap Singh --- test/widgets/compose_box_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index dbb75b77eb..747bc88f76 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -1095,4 +1095,4 @@ void main() { maxHeight: verticalPadding + 170 * 1.5, maxVisibleLines: 6); }); }); -} \ No newline at end of file +}