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