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