@@ -681,16 +681,113 @@ class _TopicInput extends StatefulWidget {
681
681
}
682
682
683
683
class _TopicInputState extends State <_TopicInput > {
684
+ void _topicFocusChanged () {
685
+ setState (() {
686
+ if (widget.controller.topicFocusNode.hasFocus) {
687
+ widget.controller.topicInteractionStatus.value =
688
+ ComposeTopicInteractionStatus .isEditing;
689
+ } else if (! widget.controller.contentFocusNode.hasFocus) {
690
+ widget.controller.topicInteractionStatus.value =
691
+ ComposeTopicInteractionStatus .notEditingNotChosen;
692
+ }
693
+ });
694
+ }
695
+
696
+ void _contentFocusChanged () {
697
+ setState (() {
698
+ if (widget.controller.contentFocusNode.hasFocus) {
699
+ widget.controller.topicInteractionStatus.value =
700
+ ComposeTopicInteractionStatus .hasChosen;
701
+ }
702
+ });
703
+ }
704
+
705
+ void _topicInteractionStatusChanged () {
706
+ setState (() {
707
+ // The actual state lives in widget.controller.topicInteractionStatus
708
+ });
709
+ }
710
+
711
+ @override
712
+ void initState () {
713
+ super .initState ();
714
+ widget.controller.topicFocusNode.addListener (_topicFocusChanged);
715
+ widget.controller.contentFocusNode.addListener (_contentFocusChanged);
716
+ widget.controller.topicInteractionStatus.addListener (_topicInteractionStatusChanged);
717
+ }
718
+
719
+ @override
720
+ void didUpdateWidget (covariant _TopicInput oldWidget) {
721
+ super .didUpdateWidget (oldWidget);
722
+ if (oldWidget.controller != widget.controller) {
723
+ oldWidget.controller.topicFocusNode.removeListener (_topicFocusChanged);
724
+ widget.controller.topicFocusNode.addListener (_topicFocusChanged);
725
+ oldWidget.controller.contentFocusNode.removeListener (_contentFocusChanged);
726
+ widget.controller.contentFocusNode.addListener (_contentFocusChanged);
727
+ oldWidget.controller.topicInteractionStatus.removeListener (_topicInteractionStatusChanged);
728
+ widget.controller.topicInteractionStatus.addListener (_topicInteractionStatusChanged);
729
+ }
730
+ }
731
+
732
+ @override
733
+ void dispose () {
734
+ widget.controller.topicFocusNode.removeListener (_topicFocusChanged);
735
+ widget.controller.contentFocusNode.removeListener (_contentFocusChanged);
736
+ widget.controller.topicInteractionStatus.removeListener (_topicInteractionStatusChanged);
737
+ super .dispose ();
738
+ }
739
+
684
740
@override
685
741
Widget build (BuildContext context) {
686
742
final zulipLocalizations = ZulipLocalizations .of (context);
687
743
final designVariables = DesignVariables .of (context);
688
- TextStyle topicTextStyle = TextStyle (
744
+ final store = PerAccountStoreWidget .of (context);
745
+
746
+ final topicTextStyle = TextStyle (
689
747
fontSize: 20 ,
690
748
height: 22 / 20 ,
691
749
color: designVariables.textInput.withFadedAlpha (0.9 ),
692
750
).merge (weightVariableTextStyle (context, wght: 600 ));
693
751
752
+ // TODO(server-10) simplify away
753
+ final emptyTopicsSupported = store.zulipFeatureLevel >= 334 ;
754
+
755
+ final String hintText;
756
+ TextStyle hintStyle = topicTextStyle.copyWith (
757
+ color: designVariables.textInput.withFadedAlpha (0.5 ));
758
+
759
+ if (store.realmMandatoryTopics) {
760
+ // Something short and not distracting.
761
+ hintText = zulipLocalizations.composeBoxTopicHintText;
762
+ } else {
763
+ switch (widget.controller.topicInteractionStatus.value) {
764
+ case ComposeTopicInteractionStatus .notEditingNotChosen:
765
+ // Something short and not distracting.
766
+ hintText = zulipLocalizations.composeBoxTopicHintText;
767
+ case ComposeTopicInteractionStatus .isEditing:
768
+ // The user is actively interacting with the input. Since topics are
769
+ // not mandatory, show a long hint text mentioning that they can be
770
+ // left empty.
771
+ hintText = zulipLocalizations.composeBoxEnterTopicOrSkipHintText (
772
+ emptyTopicsSupported
773
+ ? store.realmEmptyTopicDisplayName
774
+ : kNoTopicTopic);
775
+ case ComposeTopicInteractionStatus .hasChosen:
776
+ // The topic has likely been chosen. Since topics are not mandatory,
777
+ // show the default topic display name as if the user has entered that
778
+ // when they left the input empty.
779
+ if (emptyTopicsSupported) {
780
+ hintText = store.realmEmptyTopicDisplayName;
781
+ hintStyle = topicTextStyle.copyWith (fontStyle: FontStyle .italic);
782
+ } else {
783
+ hintText = kNoTopicTopic;
784
+ hintStyle = topicTextStyle;
785
+ }
786
+ }
787
+ }
788
+
789
+ final decoration = InputDecoration (hintText: hintText, hintStyle: hintStyle);
790
+
694
791
return TopicAutocomplete (
695
792
streamId: widget.streamId,
696
793
controller: widget.controller.topic,
@@ -706,10 +803,7 @@ class _TopicInputState extends State<_TopicInput> {
706
803
focusNode: widget.controller.topicFocusNode,
707
804
textInputAction: TextInputAction .next,
708
805
style: topicTextStyle,
709
- decoration: InputDecoration (
710
- hintText: zulipLocalizations.composeBoxTopicHintText,
711
- hintStyle: topicTextStyle.copyWith (
712
- color: designVariables.textInput.withFadedAlpha (0.5 ))))));
806
+ decoration: decoration)));
713
807
}
714
808
}
715
809
@@ -1382,17 +1476,67 @@ sealed class ComposeBoxController {
1382
1476
}
1383
1477
}
1384
1478
1479
+ /// Represent how a user has interacted with topic and content inputs.
1480
+ ///
1481
+ /// State-transition diagram:
1482
+ ///
1483
+ /// ```
1484
+ /// (default)
1485
+ /// Topic input │ Content input
1486
+ /// lost focus. ▼ gained focus.
1487
+ /// ┌────────────► notEditingNotChosen ────────────┐
1488
+ /// │ │ │
1489
+ /// │ Topic input │ │
1490
+ /// │ gained focus. │ │
1491
+ /// │ ◄─────────────────────────┘ ▼
1492
+ /// isEditing ◄───────────────────────────── hasChosen
1493
+ /// │ Focus moved from ▲ │ ▲
1494
+ /// │ content to topic. │ │ │
1495
+ /// │ │ │ │
1496
+ /// └──────────────────────────────────────┘ └─────┘
1497
+ /// Focus moved from Content input loses focus
1498
+ /// topic to content. without topic input gaining it.
1499
+ /// ```
1500
+ ///
1501
+ /// This state machine offers the following invariants:
1502
+ /// - When topic input has focus, the status must be [isEditing] .
1503
+ /// - When content input has focus, the status must be [hasChosen] .
1504
+ /// - When neither inputs have focus, and content input was the last
1505
+ /// input among the two to be focused, the status must be [hasChosen] .
1506
+ /// - Otherwise, the status must be [notEditingNotChosen] .
1507
+ enum ComposeTopicInteractionStatus {
1508
+ /// The topic has likely not been chosen if left empty,
1509
+ /// and is not being actively edited.
1510
+ ///
1511
+ /// When in this status neither the topic input nor the content input has focus.
1512
+ notEditingNotChosen,
1513
+
1514
+ /// The topic is being actively edited.
1515
+ ///
1516
+ /// When in this status, the topic input must have focus.
1517
+ isEditing,
1518
+
1519
+ /// The topic has likely been chosen, even if it is left empty.
1520
+ ///
1521
+ /// When in this status, the topic input must have no focus;
1522
+ /// the content input might have focus.
1523
+ hasChosen,
1524
+ }
1525
+
1385
1526
class StreamComposeBoxController extends ComposeBoxController {
1386
1527
StreamComposeBoxController ({required PerAccountStore store})
1387
1528
: topic = ComposeTopicController (store: store);
1388
1529
1389
1530
final ComposeTopicController topic;
1390
1531
final topicFocusNode = FocusNode ();
1532
+ final ValueNotifier <ComposeTopicInteractionStatus > topicInteractionStatus =
1533
+ ValueNotifier (ComposeTopicInteractionStatus .notEditingNotChosen);
1391
1534
1392
1535
@override
1393
1536
void dispose () {
1394
1537
topic.dispose ();
1395
1538
topicFocusNode.dispose ();
1539
+ topicInteractionStatus.dispose ();
1396
1540
super .dispose ();
1397
1541
}
1398
1542
}
0 commit comments