@@ -394,24 +394,136 @@ class ComposeContentController extends ComposeController<ContentValidationError>
394394 }
395395}
396396
397- class _ContentInput extends StatefulWidget {
398- const _ContentInput ({
397+ abstract class _ContentInput extends StatefulWidget {
398+ /// A content input that operates [PerAccountStore.typingNotifier]
399+ /// to send the self-user's typing-status updates.
400+ ///
401+ /// The typing-status updates will be sent to [destination] .
402+ factory _ContentInput .withTypingNotifier ({
403+ required Narrow narrow,
404+ required SendableNarrow destination,
405+ required ComposeBoxController controller,
406+ required String hintText,
407+ }) => _ContentInputWithTypingNotifier ._(
408+ narrow: narrow,
409+ destination: destination,
410+ controller: controller,
411+ hintText: hintText,
412+ );
413+
414+ // We'll use this soon.
415+ // ignore: unused_element
416+ factory _ContentInput .noTypingNotifier ({
417+ required Narrow narrow,
418+ required ComposeBoxController controller,
419+ required String hintText,
420+ }) => _ContentInputNoTypingNotifier ._(
421+ narrow: narrow,
422+ controller: controller,
423+ hintText: hintText,
424+ );
425+
426+ const _ContentInput ._({
399427 required this .narrow,
400- required this .destination,
401428 required this .controller,
402429 required this .hintText,
403430 });
404431
405432 final Narrow narrow;
406- final SendableNarrow destination;
407433 final ComposeBoxController controller;
408434 final String hintText;
435+ }
436+
437+ abstract class _ContentInputState <T extends _ContentInput > extends State <T > {
438+ static double maxHeight (BuildContext context) {
439+ final clampingTextScaler = MediaQuery .textScalerOf (context)
440+ .clamp (maxScaleFactor: 1.5 );
441+ final scaledLineHeight = clampingTextScaler.scale (_fontSize) * _lineHeightRatio;
442+
443+ // Reserve space to fully show the first 7th lines and just partially
444+ // clip the 8th line, where the height matches the spec at
445+ // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3960-5147&node-type=text&m=dev
446+ // > Maximum size of the compose box is suggested to be 178px. Which
447+ // > has 7 fully visible lines of text
448+ //
449+ // The partial line hints that the content input is scrollable.
450+ //
451+ // Using the ambient TextScale means this works for different values of the
452+ // system text-size setting. We clamp to a max scale factor to limit
453+ // how tall the content input can get; that's to save room for the message
454+ // list. The user can still scroll the input to see everything.
455+ return _verticalPadding + 7.727 * scaledLineHeight;
456+ }
457+
458+ static const _verticalPadding = 8.0 ;
459+ static const _fontSize = 17.0 ;
460+ static const _lineHeight = 22.0 ;
461+ static const _lineHeightRatio = _lineHeight / _fontSize;
462+
463+ @override
464+ Widget build (BuildContext context) {
465+ final designVariables = DesignVariables .of (context);
466+
467+ return ComposeAutocomplete (
468+ narrow: widget.narrow,
469+ controller: widget.controller.content,
470+ focusNode: widget.controller.contentFocusNode,
471+ fieldViewBuilder: (context) => ConstrainedBox (
472+ constraints: BoxConstraints (maxHeight: maxHeight (context)),
473+ // This [ClipRect] replaces the [TextField] clipping we disable below.
474+ child: ClipRect (
475+ child: InsetShadowBox (
476+ top: _verticalPadding, bottom: _verticalPadding,
477+ color: designVariables.composeBoxBg,
478+ child: TextField (
479+ controller: widget.controller.content,
480+ focusNode: widget.controller.contentFocusNode,
481+ // Let the content show through the `contentPadding` so that
482+ // our [InsetShadowBox] can fade it smoothly there.
483+ clipBehavior: Clip .none,
484+ style: TextStyle (
485+ fontSize: _fontSize,
486+ height: _lineHeightRatio,
487+ color: designVariables.textInput),
488+ // From the spec at
489+ // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3960-5147&node-type=text&m=dev
490+ // > Compose box has the height to fit 2 lines. This is [done] to
491+ // > have a bigger hit area for the user to start the input. […]
492+ minLines: 2 ,
493+ maxLines: null ,
494+ textCapitalization: TextCapitalization .sentences,
495+ decoration: InputDecoration (
496+ // This padding ensures that the user can always scroll long
497+ // content entirely out of the top or bottom shadow if desired.
498+ // With this and the `minLines: 2` above, an empty content input
499+ // gets 60px vertical distance (with no text-size scaling)
500+ // between the top of the top shadow and the bottom of the
501+ // bottom shadow. That's a bit more than the 54px given in the
502+ // Figma, and we can revisit if needed, but it's tricky to get
503+ // that 54px distance while also making the scrolling work like
504+ // this and offering two lines of touchable area.
505+ contentPadding: const EdgeInsets .symmetric (vertical: _verticalPadding),
506+ hintText: widget.hintText,
507+ hintStyle: TextStyle (
508+ color: designVariables.textInput.withFadedAlpha (0.5 ))))))));
509+ }
510+ }
511+
512+ class _ContentInputWithTypingNotifier extends _ContentInput {
513+ const _ContentInputWithTypingNotifier ._({
514+ required super .narrow,
515+ required this .destination,
516+ required super .controller,
517+ required super .hintText,
518+ }) : super ._();
519+
520+ final SendableNarrow destination;
409521
410522 @override
411- State <_ContentInput > createState () => _ContentInputState ();
523+ State <_ContentInput > createState () => _ContentInputStateWithTypingNotifier ();
412524}
413525
414- class _ContentInputState extends State < _ContentInput > with WidgetsBindingObserver {
526+ class _ContentInputStateWithTypingNotifier extends _ContentInputState < _ContentInputWithTypingNotifier > with WidgetsBindingObserver {
415527 @override
416528 void initState () {
417529 super .initState ();
@@ -421,7 +533,7 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve
421533 }
422534
423535 @override
424- void didUpdateWidget (covariant _ContentInput oldWidget) {
536+ void didUpdateWidget (covariant _ContentInputWithTypingNotifier oldWidget) {
425537 super .didUpdateWidget (oldWidget);
426538 if (widget.controller != oldWidget.controller) {
427539 oldWidget.controller.content.removeListener (_contentChanged);
@@ -483,81 +595,21 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve
483595 case AppLifecycleState .resumed:
484596 }
485597 }
598+ }
486599
487- static double maxHeight (BuildContext context) {
488- final clampingTextScaler = MediaQuery .textScalerOf (context)
489- .clamp (maxScaleFactor: 1.5 );
490- final scaledLineHeight = clampingTextScaler.scale (_fontSize) * _lineHeightRatio;
491-
492- // Reserve space to fully show the first 7th lines and just partially
493- // clip the 8th line, where the height matches the spec at
494- // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3960-5147&node-type=text&m=dev
495- // > Maximum size of the compose box is suggested to be 178px. Which
496- // > has 7 fully visible lines of text
497- //
498- // The partial line hints that the content input is scrollable.
499- //
500- // Using the ambient TextScale means this works for different values of the
501- // system text-size setting. We clamp to a max scale factor to limit
502- // how tall the content input can get; that's to save room for the message
503- // list. The user can still scroll the input to see everything.
504- return _verticalPadding + 7.727 * scaledLineHeight;
505- }
506-
507- static const _verticalPadding = 8.0 ;
508- static const _fontSize = 17.0 ;
509- static const _lineHeight = 22.0 ;
510- static const _lineHeightRatio = _lineHeight / _fontSize;
600+ class _ContentInputNoTypingNotifier extends _ContentInput {
601+ const _ContentInputNoTypingNotifier ._({
602+ required super .narrow,
603+ required super .controller,
604+ required super .hintText,
605+ }) : super ._();
511606
512607 @override
513- Widget build (BuildContext context) {
514- final designVariables = DesignVariables .of (context);
515-
516- return ComposeAutocomplete (
517- narrow: widget.narrow,
518- controller: widget.controller.content,
519- focusNode: widget.controller.contentFocusNode,
520- fieldViewBuilder: (context) => ConstrainedBox (
521- constraints: BoxConstraints (maxHeight: maxHeight (context)),
522- // This [ClipRect] replaces the [TextField] clipping we disable below.
523- child: ClipRect (
524- child: InsetShadowBox (
525- top: _verticalPadding, bottom: _verticalPadding,
526- color: designVariables.composeBoxBg,
527- child: TextField (
528- controller: widget.controller.content,
529- focusNode: widget.controller.contentFocusNode,
530- // Let the content show through the `contentPadding` so that
531- // our [InsetShadowBox] can fade it smoothly there.
532- clipBehavior: Clip .none,
533- style: TextStyle (
534- fontSize: _fontSize,
535- height: _lineHeightRatio,
536- color: designVariables.textInput),
537- // From the spec at
538- // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3960-5147&node-type=text&m=dev
539- // > Compose box has the height to fit 2 lines. This is [done] to
540- // > have a bigger hit area for the user to start the input. […]
541- minLines: 2 ,
542- maxLines: null ,
543- textCapitalization: TextCapitalization .sentences,
544- decoration: InputDecoration (
545- // This padding ensures that the user can always scroll long
546- // content entirely out of the top or bottom shadow if desired.
547- // With this and the `minLines: 2` above, an empty content input
548- // gets 60px vertical distance (with no text-size scaling)
549- // between the top of the top shadow and the bottom of the
550- // bottom shadow. That's a bit more than the 54px given in the
551- // Figma, and we can revisit if needed, but it's tricky to get
552- // that 54px distance while also making the scrolling work like
553- // this and offering two lines of touchable area.
554- contentPadding: const EdgeInsets .symmetric (vertical: _verticalPadding),
555- hintText: widget.hintText,
556- hintStyle: TextStyle (
557- color: designVariables.textInput.withFadedAlpha (0.5 ))))))));
558- }
608+ State <_ContentInput > createState () => _ContentInputStateNoTypingNotifier ();
559609}
560610
611+ class _ContentInputStateNoTypingNotifier extends _ContentInputState <_ContentInputNoTypingNotifier > {}
612+
561613/// The content input for _StreamComposeBox.
562614class _StreamContentInput extends StatefulWidget {
563615 const _StreamContentInput ({required this .narrow, required this .controller});
@@ -645,7 +697,7 @@ class _StreamContentInputState extends State<_StreamContentInput> {
645697 // ignore: dead_null_aware_expression // null topic names soon to be enabled
646698 : '#$streamName > ${hintTopic .displayName ?? store .realmEmptyTopicDisplayName }' ;
647699
648- return _ContentInput (
700+ return _ContentInput . withTypingNotifier (
649701 narrow: widget.narrow,
650702 destination: TopicNarrow (widget.narrow.streamId,
651703 TopicName (widget.controller.topic.textNormalized)),
@@ -732,7 +784,7 @@ class _FixedDestinationContentInput extends StatelessWidget {
732784
733785 @override
734786 Widget build (BuildContext context) {
735- return _ContentInput (
787+ return _ContentInput . withTypingNotifier (
736788 narrow: narrow,
737789 destination: narrow,
738790 controller: controller,
0 commit comments