Skip to content

Commit 5132980

Browse files
committed
compose: Change topic input hint text
This is similar to web's behavior. When topics are not mandatory: - an alternative hint text "Enter a topic (skip for general chat)" is shown when the topic input has focus; - an opaque placeholder text (e.g.: "general chat") is shown if the user skipped to content input; Because the topic input is always shown in a message list page channel narrow (assuming permission to send messages), this also adds an intial state: - a short hint text, "Topic", is shown if the user hasn't interacted with topic or content inputs at all. This only changes the topic input's hint text. See CZO discussion for design details: https://chat.zulip.org/#narrow/channel/530-mobile-design/topic/general.20chat.20design.20.23F1297/near/2106736
1 parent ec6622f commit 5132980

12 files changed

+246
-10
lines changed

assets/l10n/app_en.arb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,13 @@
379379
"@composeBoxTopicHintText": {
380380
"description": "Hint text for topic input widget in compose box."
381381
},
382+
"composeBoxEnterTopicOrSkipHintText": "Enter a topic (skip for “{defaultTopicName}”)",
383+
"@composeBoxEnterTopicOrSkipHintText": {
384+
"description": "Hint text for topic input widget in compose box when topics are optional.",
385+
"placeholders": {
386+
"defaultTopicName": {"type": "String", "example": "general chat"}
387+
}
388+
},
382389
"composeBoxUploadingFilename": "Uploading {filename}…",
383390
"@composeBoxUploadingFilename": {
384391
"description": "Placeholder in compose box showing the specified file is currently uploading.",

lib/generated/l10n/zulip_localizations.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,12 @@ abstract class ZulipLocalizations {
609609
/// **'Topic'**
610610
String get composeBoxTopicHintText;
611611

612+
/// Hint text for topic input widget in compose box when topics are optional.
613+
///
614+
/// In en, this message translates to:
615+
/// **'Enter a topic (skip for “{defaultTopicName}”)'**
616+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName);
617+
612618
/// Placeholder in compose box showing the specified file is currently uploading.
613619
///
614620
/// In en, this message translates to:

lib/generated/l10n/zulip_localizations_ar.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,11 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
298298
@override
299299
String get composeBoxTopicHintText => 'Topic';
300300

301+
@override
302+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) {
303+
return 'Enter a topic (skip for “$defaultTopicName”)';
304+
}
305+
301306
@override
302307
String composeBoxUploadingFilename(String filename) {
303308
return 'Uploading $filename…';

lib/generated/l10n/zulip_localizations_en.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,11 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
298298
@override
299299
String get composeBoxTopicHintText => 'Topic';
300300

301+
@override
302+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) {
303+
return 'Enter a topic (skip for “$defaultTopicName”)';
304+
}
305+
301306
@override
302307
String composeBoxUploadingFilename(String filename) {
303308
return 'Uploading $filename…';

lib/generated/l10n/zulip_localizations_ja.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,11 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
298298
@override
299299
String get composeBoxTopicHintText => 'Topic';
300300

301+
@override
302+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) {
303+
return 'Enter a topic (skip for “$defaultTopicName”)';
304+
}
305+
301306
@override
302307
String composeBoxUploadingFilename(String filename) {
303308
return 'Uploading $filename…';

lib/generated/l10n/zulip_localizations_nb.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,11 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
298298
@override
299299
String get composeBoxTopicHintText => 'Topic';
300300

301+
@override
302+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) {
303+
return 'Enter a topic (skip for “$defaultTopicName”)';
304+
}
305+
301306
@override
302307
String composeBoxUploadingFilename(String filename) {
303308
return 'Uploading $filename…';

lib/generated/l10n/zulip_localizations_pl.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,11 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
298298
@override
299299
String get composeBoxTopicHintText => 'Wątek';
300300

301+
@override
302+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) {
303+
return 'Enter a topic (skip for “$defaultTopicName”)';
304+
}
305+
301306
@override
302307
String composeBoxUploadingFilename(String filename) {
303308
return 'Przekazywanie $filename…';

lib/generated/l10n/zulip_localizations_ru.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,11 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
298298
@override
299299
String get composeBoxTopicHintText => 'Тема';
300300

301+
@override
302+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) {
303+
return 'Enter a topic (skip for “$defaultTopicName”)';
304+
}
305+
301306
@override
302307
String composeBoxUploadingFilename(String filename) {
303308
return 'Загрузка $filename…';

lib/generated/l10n/zulip_localizations_sk.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,11 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
298298
@override
299299
String get composeBoxTopicHintText => 'Topic';
300300

301+
@override
302+
String composeBoxEnterTopicOrSkipHintText(String defaultTopicName) {
303+
return 'Enter a topic (skip for “$defaultTopicName”)';
304+
}
305+
301306
@override
302307
String composeBoxUploadingFilename(String filename) {
303308
return 'Uploading $filename…';

lib/widgets/compose_box.dart

Lines changed: 134 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -682,15 +682,101 @@ class _TopicInput extends StatefulWidget {
682682
}
683683

684684
class _TopicInputState extends State<_TopicInput> {
685+
void _topicFocusChanged() {
686+
setState(() {
687+
if (widget.controller.topicFocusNode.hasFocus) {
688+
widget.controller.topicEditStatus.value = ComposeTopicEditStatus.isEditing;
689+
} else if (!widget.controller.contentFocusNode.hasFocus) {
690+
widget.controller.topicEditStatus.value = ComposeTopicEditStatus.none;
691+
}
692+
});
693+
}
694+
695+
void _contentFocusChanged() {
696+
setState(() {
697+
if (widget.controller.contentFocusNode.hasFocus) {
698+
widget.controller.topicEditStatus.value = ComposeTopicEditStatus.hasChosen;
699+
}
700+
});
701+
}
702+
703+
void _topicEditStatusChanged() {
704+
setState(() {
705+
// The actual state lives in widget.controller.topicEditStatus
706+
});
707+
}
708+
709+
@override
710+
void initState() {
711+
super.initState();
712+
widget.controller.topicFocusNode.addListener(_topicFocusChanged);
713+
widget.controller.contentFocusNode.addListener(_contentFocusChanged);
714+
widget.controller.topicEditStatus.addListener(_topicEditStatusChanged);
715+
}
716+
717+
@override
718+
void didUpdateWidget(covariant _TopicInput oldWidget) {
719+
super.didUpdateWidget(oldWidget);
720+
if (oldWidget.controller != widget.controller) {
721+
oldWidget.controller.topicFocusNode.removeListener(_topicFocusChanged);
722+
widget.controller.topicFocusNode.addListener(_topicFocusChanged);
723+
oldWidget.controller.contentFocusNode.removeListener(_contentFocusChanged);
724+
widget.controller.contentFocusNode.addListener(_contentFocusChanged);
725+
oldWidget.controller.topicEditStatus.removeListener(_topicEditStatusChanged);
726+
widget.controller.topicEditStatus.addListener(_topicEditStatusChanged);
727+
}
728+
}
729+
730+
@override
731+
void dispose() {
732+
widget.controller.topicFocusNode.removeListener(_topicFocusChanged);
733+
widget.controller.contentFocusNode.removeListener(_contentFocusChanged);
734+
widget.controller.topicEditStatus.removeListener(_topicEditStatusChanged);
735+
super.dispose();
736+
}
737+
685738
@override
686739
Widget build(BuildContext context) {
687740
final zulipLocalizations = ZulipLocalizations.of(context);
688741
final designVariables = DesignVariables.of(context);
689-
TextStyle topicTextStyle = TextStyle(
742+
final store = PerAccountStoreWidget.of(context);
743+
744+
final topicTextStyle = TextStyle(
690745
fontSize: 20,
691746
height: 22 / 20,
692747
color: designVariables.textInput.withFadedAlpha(0.9),
693748
).merge(weightVariableTextStyle(context, wght: 600));
749+
final hintStyle = topicTextStyle.copyWith(
750+
color: designVariables.textInput.withFadedAlpha(0.5));
751+
final defaultTopicDisplayName = store.zulipFeatureLevel >= 334
752+
? store.realmEmptyTopicDisplayName : kNoTopicTopic;
753+
754+
final decoration = switch ((
755+
store.realmMandatoryTopics,
756+
widget.controller.topicEditStatus.value,
757+
)) {
758+
(false, ComposeTopicEditStatus.hasChosen) => InputDecoration(
759+
// The topic has likely been chosen. Since topics are not mandaotry,
760+
// show the default topic display name as if the user has entered that
761+
// when they left the input empty.
762+
hintText: defaultTopicDisplayName,
763+
hintStyle: topicTextStyle.copyWith(
764+
fontStyle: store.zulipFeatureLevel >= 334 ? FontStyle.italic : null)),
765+
766+
(false, ComposeTopicEditStatus.isEditing) => InputDecoration(
767+
// The user is actively interacting with the input. Since topics are
768+
// not mandatory, show a long hint text mentioning that they can be
769+
// left empty.
770+
hintText: zulipLocalizations.composeBoxEnterTopicOrSkipHintText(
771+
defaultTopicDisplayName),
772+
hintStyle: hintStyle),
773+
774+
(false, ComposeTopicEditStatus.none) ||
775+
(true, _) => InputDecoration(
776+
// Otherwise, show a short hint text for less distraction.
777+
hintText: zulipLocalizations.composeBoxTopicHintText,
778+
hintStyle: hintStyle),
779+
};
694780

695781
return TopicAutocomplete(
696782
streamId: widget.streamId,
@@ -707,10 +793,7 @@ class _TopicInputState extends State<_TopicInput> {
707793
focusNode: widget.controller.topicFocusNode,
708794
textInputAction: TextInputAction.next,
709795
style: topicTextStyle,
710-
decoration: InputDecoration(
711-
hintText: zulipLocalizations.composeBoxTopicHintText,
712-
hintStyle: topicTextStyle.copyWith(
713-
color: designVariables.textInput.withFadedAlpha(0.5))))));
796+
decoration: decoration)));
714797
}
715798
}
716799

@@ -1383,17 +1466,63 @@ sealed class ComposeBoxController {
13831466
}
13841467
}
13851468

1469+
/// Represent how a user has edited the topic, by tracking their previous
1470+
/// interactions with topic and content inputs.
1471+
///
1472+
/// State-transition diagram:
1473+
///
1474+
/// ```
1475+
/// content input
1476+
/// gains focus
1477+
/// none ─────────────► hasChosen
1478+
/// ▲ │ │ ▲
1479+
/// │ └────────────────┤ │
1480+
/// │ topic input │ │ content input
1481+
/// │ gains focus │ │ gains focus
1482+
/// │ ▼ │
1483+
/// └────────────────── isEditing
1484+
/// topic input loses focus
1485+
/// and content input has no focus
1486+
/// ```
1487+
///
1488+
/// This state machine offers the following invariants:
1489+
/// - When topic input has focus, the edit status must be [isEditing].
1490+
/// - When content input has focus, the edit status must be [hasChosen].
1491+
/// - Otherwise, the edit status can be either of [none] or [hasChosen],
1492+
/// but never [isEditing].
1493+
enum ComposeTopicEditStatus {
1494+
/// The topic has likely not been chosen if left empty,
1495+
/// and is not being actively edited.
1496+
///
1497+
/// When in this status neither the topic input nor the content input has focus.
1498+
none,
1499+
1500+
/// The topic has likely been chosen, even if it is left empty.
1501+
///
1502+
/// When in this status, the topic input must have no focus;
1503+
/// the content input might have focus.
1504+
hasChosen,
1505+
1506+
/// The topic is being actively edited.
1507+
///
1508+
/// When in this status, the topic input must have focus.
1509+
isEditing,
1510+
}
1511+
13861512
class StreamComposeBoxController extends ComposeBoxController {
13871513
StreamComposeBoxController({required PerAccountStore store})
13881514
: topic = ComposeTopicController(store: store);
13891515

13901516
final ComposeTopicController topic;
13911517
final topicFocusNode = FocusNode();
1518+
final ValueNotifier<ComposeTopicEditStatus> topicEditStatus =
1519+
ValueNotifier(ComposeTopicEditStatus.none);
13921520

13931521
@override
13941522
void dispose() {
13951523
topic.dispose();
13961524
topicFocusNode.dispose();
1525+
topicEditStatus.dispose();
13971526
super.dispose();
13981527
}
13991528
}

test/flutter_checks.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ extension TextStyleChecks on Subject<TextStyle> {
9999
Subject<bool> get inherit => has((t) => t.inherit, 'inherit');
100100
Subject<Color?> get color => has((t) => t.color, 'color');
101101
Subject<double?> get fontSize => has((t) => t.fontSize, 'fontSize');
102+
Subject<FontStyle?> get fontStyle => has((t) => t.fontStyle, 'fontStyle');
102103
Subject<FontWeight?> get fontWeight => has((t) => t.fontWeight, 'fontWeight');
103104
Subject<double?> get letterSpacing => has((t) => t.letterSpacing, 'letterSpacing');
104105
Subject<List<FontVariation>?> get fontVariations => has((t) => t.fontVariations, 'fontVariations');
@@ -170,6 +171,7 @@ extension MaterialChecks on Subject<Material> {
170171

171172
extension InputDecorationChecks on Subject<InputDecoration> {
172173
Subject<String?> get hintText => has((x) => x.hintText, 'hintText');
174+
Subject<TextStyle?> get hintStyle => has((x) => x.hintStyle, 'hintStyle');
173175
}
174176

175177
extension RadioListTileChecks<T> on Subject<RadioListTile<T>> {

0 commit comments

Comments
 (0)