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