@@ -394,24 +394,136 @@ class ComposeContentController extends ComposeController<ContentValidationError>
394
394
}
395
395
}
396
396
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 ._({
399
427
required this .narrow,
400
- required this .destination,
401
428
required this .controller,
402
429
required this .hintText,
403
430
});
404
431
405
432
final Narrow narrow;
406
- final SendableNarrow destination;
407
433
final ComposeBoxController controller;
408
434
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;
409
521
410
522
@override
411
- State <_ContentInput > createState () => _ContentInputState ();
523
+ State <_ContentInput > createState () => _ContentInputStateWithTypingNotifier ();
412
524
}
413
525
414
- class _ContentInputState extends State < _ContentInput > with WidgetsBindingObserver {
526
+ class _ContentInputStateWithTypingNotifier extends _ContentInputState < _ContentInputWithTypingNotifier > with WidgetsBindingObserver {
415
527
@override
416
528
void initState () {
417
529
super .initState ();
@@ -421,7 +533,7 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve
421
533
}
422
534
423
535
@override
424
- void didUpdateWidget (covariant _ContentInput oldWidget) {
536
+ void didUpdateWidget (covariant _ContentInputWithTypingNotifier oldWidget) {
425
537
super .didUpdateWidget (oldWidget);
426
538
if (widget.controller != oldWidget.controller) {
427
539
oldWidget.controller.content.removeListener (_contentChanged);
@@ -483,81 +595,21 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve
483
595
case AppLifecycleState .resumed:
484
596
}
485
597
}
598
+ }
486
599
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 ._();
511
606
512
607
@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 ();
559
609
}
560
610
611
+ class _ContentInputStateNoTypingNotifier extends _ContentInputState <_ContentInputNoTypingNotifier > {}
612
+
561
613
/// The content input for _StreamComposeBox.
562
614
class _StreamContentInput extends StatefulWidget {
563
615
const _StreamContentInput ({required this .narrow, required this .controller});
@@ -645,7 +697,7 @@ class _StreamContentInputState extends State<_StreamContentInput> {
645
697
// ignore: dead_null_aware_expression // null topic names soon to be enabled
646
698
: '#$streamName > ${hintTopic .displayName ?? store .realmEmptyTopicDisplayName }' ;
647
699
648
- return _ContentInput (
700
+ return _ContentInput . withTypingNotifier (
649
701
narrow: widget.narrow,
650
702
destination: TopicNarrow (widget.narrow.streamId,
651
703
TopicName (widget.controller.topic.textNormalized)),
@@ -732,7 +784,7 @@ class _FixedDestinationContentInput extends StatelessWidget {
732
784
733
785
@override
734
786
Widget build (BuildContext context) {
735
- return _ContentInput (
787
+ return _ContentInput . withTypingNotifier (
736
788
narrow: narrow,
737
789
destination: narrow,
738
790
controller: controller,
0 commit comments