@@ -295,6 +295,7 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve
295
295
super .initState ();
296
296
widget.controller.content.addListener (_contentChanged);
297
297
widget.controller.contentFocusNode.addListener (_focusChanged);
298
+ widget.controller._enabled.addListener (_enabledChanged);
298
299
WidgetsBinding .instance.addObserver (this );
299
300
}
300
301
@@ -306,13 +307,16 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve
306
307
widget.controller.content.addListener (_contentChanged);
307
308
oldWidget.controller.contentFocusNode.removeListener (_focusChanged);
308
309
widget.controller.contentFocusNode.addListener (_focusChanged);
310
+ oldWidget.controller._enabled.removeListener (_enabledChanged);
311
+ widget.controller._enabled.addListener (_enabledChanged);
309
312
}
310
313
}
311
314
312
315
@override
313
316
void dispose () {
314
317
widget.controller.content.removeListener (_contentChanged);
315
318
widget.controller.contentFocusNode.removeListener (_focusChanged);
319
+ widget.controller._enabled.removeListener (_enabledChanged);
316
320
WidgetsBinding .instance.removeObserver (this );
317
321
super .dispose ();
318
322
}
@@ -334,6 +338,12 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve
334
338
store.typingNotifier.stoppedComposing ();
335
339
}
336
340
341
+ void _enabledChanged () {
342
+ setState (() {
343
+ // The actual state lives in `widget.controller._enabled`.
344
+ });
345
+ }
346
+
337
347
@override
338
348
void didChangeAppLifecycleState (AppLifecycleState state) {
339
349
switch (state) {
@@ -395,44 +405,47 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve
395
405
narrow: widget.narrow,
396
406
controller: widget.controller.content,
397
407
focusNode: widget.controller.contentFocusNode,
398
- fieldViewBuilder: (context) => ConstrainedBox (
399
- constraints: BoxConstraints (maxHeight: maxHeight (context)),
400
- // This [ClipRect] replaces the [TextField] clipping we disable below.
401
- child: ClipRect (
402
- child: InsetShadowBox (
403
- top: _verticalPadding, bottom: _verticalPadding,
404
- color: designVariables.composeBoxBg,
405
- child: TextField (
406
- controller: widget.controller.content,
407
- focusNode: widget.controller.contentFocusNode,
408
- // Let the content show through the `contentPadding` so that
409
- // our [InsetShadowBox] can fade it smoothly there.
410
- clipBehavior: Clip .none,
411
- style: TextStyle (
412
- fontSize: _fontSize,
413
- height: _lineHeightRatio,
414
- color: designVariables.textInput),
415
- // From the spec at
416
- // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3960-5147&node-type=text&m=dev
417
- // > Compose box has the height to fit 2 lines. This is [done] to
418
- // > have a bigger hit area for the user to start the input. […]
419
- minLines: 2 ,
420
- maxLines: null ,
421
- textCapitalization: TextCapitalization .sentences,
422
- decoration: InputDecoration (
423
- // This padding ensures that the user can always scroll long
424
- // content entirely out of the top or bottom shadow if desired.
425
- // With this and the `minLines: 2` above, an empty content input
426
- // gets 60px vertical distance (with no text-size scaling)
427
- // between the top of the top shadow and the bottom of the
428
- // bottom shadow. That's a bit more than the 54px given in the
429
- // Figma, and we can revisit if needed, but it's tricky to get
430
- // that 54px distance while also making the scrolling work like
431
- // this and offering two lines of touchable area.
432
- contentPadding: const EdgeInsets .symmetric (vertical: _verticalPadding),
433
- hintText: widget.hintText,
434
- hintStyle: TextStyle (
435
- color: designVariables.textInput.withFadedAlpha (0.5 ))))))));
408
+ fieldViewBuilder: (context) => Opacity (
409
+ opacity: widget.controller.enabled ? 1 : 0.5 ,
410
+ child: ConstrainedBox (
411
+ constraints: BoxConstraints (maxHeight: maxHeight (context)),
412
+ // This [ClipRect] replaces the [TextField] clipping we disable below.
413
+ child: ClipRect (
414
+ child: InsetShadowBox (
415
+ top: _verticalPadding, bottom: _verticalPadding,
416
+ color: designVariables.composeBoxBg,
417
+ child: TextField (
418
+ enabled: widget.controller.enabled,
419
+ controller: widget.controller.content,
420
+ focusNode: widget.controller.contentFocusNode,
421
+ // Let the content show through the `contentPadding` so that
422
+ // our [InsetShadowBox] can fade it smoothly there.
423
+ clipBehavior: Clip .none,
424
+ style: TextStyle (
425
+ fontSize: _fontSize,
426
+ height: _lineHeightRatio,
427
+ color: designVariables.textInput),
428
+ // From the spec at
429
+ // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3960-5147&node-type=text&m=dev
430
+ // > Compose box has the height to fit 2 lines. This is [done] to
431
+ // > have a bigger hit area for the user to start the input. […]
432
+ minLines: 2 ,
433
+ maxLines: null ,
434
+ textCapitalization: TextCapitalization .sentences,
435
+ decoration: InputDecoration (
436
+ // This padding ensures that the user can always scroll long
437
+ // content entirely out of the top or bottom shadow if desired.
438
+ // With this and the `minLines: 2` above, an empty content input
439
+ // gets 60px vertical distance (with no text-size scaling)
440
+ // between the top of the top shadow and the bottom of the
441
+ // bottom shadow. That's a bit more than the 54px given in the
442
+ // Figma, and we can revisit if needed, but it's tricky to get
443
+ // that 54px distance while also making the scrolling work like
444
+ // this and offering two lines of touchable area.
445
+ contentPadding: const EdgeInsets .symmetric (vertical: _verticalPadding),
446
+ hintText: widget.hintText,
447
+ hintStyle: TextStyle (
448
+ color: designVariables.textInput.withFadedAlpha (0.5 )))))))));
436
449
}
437
450
}
438
451
@@ -513,20 +526,28 @@ class _TopicInput extends StatelessWidget {
513
526
controller: controller.topic,
514
527
focusNode: controller.topicFocusNode,
515
528
contentFocusNode: controller.contentFocusNode,
516
- fieldViewBuilder: (context) => Container (
517
- padding: const EdgeInsets .only (top: 10 , bottom: 9 ),
518
- decoration: BoxDecoration (border: Border (bottom: BorderSide (
519
- width: 1 ,
520
- color: designVariables.foreground.withFadedAlpha (0.2 )))),
521
- child: TextField (
522
- controller: controller.topic,
523
- focusNode: controller.topicFocusNode,
524
- textInputAction: TextInputAction .next,
525
- style: topicTextStyle,
526
- decoration: InputDecoration (
527
- hintText: zulipLocalizations.composeBoxTopicHintText,
528
- hintStyle: topicTextStyle.copyWith (
529
- color: designVariables.textInput.withFadedAlpha (0.5 ))))));
529
+ fieldViewBuilder: (context) => ValueListenableBuilder (
530
+ valueListenable: controller._enabled,
531
+ builder: (context, enabled, child) {
532
+ return Opacity (
533
+ opacity: enabled ? 1 : 0.5 ,
534
+ child: Container (
535
+ padding: const EdgeInsets .only (top: 10 , bottom: 9 ),
536
+ decoration: BoxDecoration (border: Border (bottom: BorderSide (
537
+ width: 1 ,
538
+ color: designVariables.foreground.withFadedAlpha (0.2 )))),
539
+ child: TextField (
540
+ enabled: enabled,
541
+ controller: controller.topic,
542
+ focusNode: controller.topicFocusNode,
543
+ textInputAction: TextInputAction .next,
544
+ style: topicTextStyle,
545
+ decoration: InputDecoration (
546
+ hintText: zulipLocalizations.composeBoxTopicHintText,
547
+ hintStyle: topicTextStyle.copyWith (
548
+ color: designVariables.textInput.withFadedAlpha (0.5 ))))));
549
+ }
550
+ ));
530
551
}
531
552
}
532
553
@@ -699,10 +720,14 @@ abstract class _AttachUploadsButton extends StatelessWidget {
699
720
final zulipLocalizations = ZulipLocalizations .of (context);
700
721
return SizedBox (
701
722
width: _composeButtonSize,
702
- child: IconButton (
703
- icon: Icon (icon, color: designVariables.foreground.withFadedAlpha (0.5 )),
704
- tooltip: tooltip (zulipLocalizations),
705
- onPressed: () => _handlePress (context)));
723
+ child: ValueListenableBuilder (
724
+ valueListenable: controller._enabled,
725
+ builder: (context, enabled, child) {
726
+ return IconButton (
727
+ icon: Icon (icon, color: designVariables.foreground.withFadedAlpha (0.5 )),
728
+ tooltip: tooltip (zulipLocalizations),
729
+ onPressed: enabled ? () => _handlePress (context) : null );
730
+ }));
706
731
}
707
732
}
708
733
@@ -882,6 +907,12 @@ class _SendButtonState extends State<_SendButton> {
882
907
});
883
908
}
884
909
910
+ void _hasEnabledChanged () {
911
+ setState (() {
912
+ // The actual state lives in `widget.controller._enabled`.
913
+ });
914
+ }
915
+
885
916
@override
886
917
void initState () {
887
918
super .initState ();
@@ -890,6 +921,7 @@ class _SendButtonState extends State<_SendButton> {
890
921
controller.topic.hasValidationErrors.addListener (_hasErrorsChanged);
891
922
}
892
923
controller.content.hasValidationErrors.addListener (_hasErrorsChanged);
924
+ controller._enabled.addListener (_hasEnabledChanged);
893
925
}
894
926
895
927
@override
@@ -908,6 +940,8 @@ class _SendButtonState extends State<_SendButton> {
908
940
}
909
941
oldController.content.hasValidationErrors.removeListener (_hasErrorsChanged);
910
942
controller.content.hasValidationErrors.addListener (_hasErrorsChanged);
943
+ oldController._enabled.removeListener (_hasEnabledChanged);
944
+ controller._enabled.addListener (_hasEnabledChanged);
911
945
}
912
946
913
947
@override
@@ -917,6 +951,7 @@ class _SendButtonState extends State<_SendButton> {
917
951
controller.topic.hasValidationErrors.removeListener (_hasErrorsChanged);
918
952
}
919
953
controller.content.hasValidationErrors.removeListener (_hasErrorsChanged);
954
+ controller._enabled.removeListener (_hasEnabledChanged);
920
955
super .dispose ();
921
956
}
922
957
@@ -950,20 +985,20 @@ class _SendButtonState extends State<_SendButton> {
950
985
return ;
951
986
}
952
987
988
+ if (! widget.controller.enabled) {
989
+ // TODO update comment
990
+ return ;
991
+ }
992
+
953
993
final store = PerAccountStoreWidget .of (context);
954
994
final content = controller.content.textNormalized;
955
995
956
- controller.content.clear ();
957
- // The following `stoppedComposing` call is currently redundant,
958
- // because clearing input sends a "typing stopped" notice.
959
- // It will be necessary once we resolve #720.
960
996
store.typingNotifier.stoppedComposing ();
997
+ widget.controller._enabled.value = false ;
961
998
962
999
try {
963
- // TODO(#720) clear content input only on success response;
964
- // while waiting, put input(s) and send button into a disabled
965
- // "working on it" state (letting input text be selected for copying).
966
1000
await store.sendMessage (destination: widget.getDestination (), content: content);
1001
+ widget.controller.content.clear ();
967
1002
} on ApiRequestException catch (e) {
968
1003
if (! mounted) return ;
969
1004
final zulipLocalizations = ZulipLocalizations .of (context);
@@ -975,6 +1010,8 @@ class _SendButtonState extends State<_SendButton> {
975
1010
title: zulipLocalizations.errorMessageNotSent,
976
1011
message: message);
977
1012
return ;
1013
+ } finally {
1014
+ widget.controller._enabled.value = true ;
978
1015
}
979
1016
}
980
1017
@@ -983,7 +1020,7 @@ class _SendButtonState extends State<_SendButton> {
983
1020
final designVariables = DesignVariables .of (context);
984
1021
final zulipLocalizations = ZulipLocalizations .of (context);
985
1022
986
- final iconColor = _hasValidationErrors
1023
+ final iconColor = _hasValidationErrors || ! widget.controller.enabled
987
1024
? designVariables.icon.withFadedAlpha (0.5 )
988
1025
: designVariables.icon;
989
1026
@@ -997,7 +1034,7 @@ class _SendButtonState extends State<_SendButton> {
997
1034
// ambient [ButtonStyle.overlayColor], where we set the color for
998
1035
// the highlight state to match the Figma design.
999
1036
color: iconColor),
1000
- onPressed: _send));
1037
+ onPressed: (widget.controller.enabled) ? _send : null ));
1001
1038
}
1002
1039
}
1003
1040
@@ -1117,7 +1154,12 @@ abstract class _ComposeBoxBody extends StatelessWidget {
1117
1154
child: Row (
1118
1155
mainAxisAlignment: MainAxisAlignment .spaceBetween,
1119
1156
children: [
1120
- Row (children: composeButtons),
1157
+ ValueListenableBuilder (
1158
+ valueListenable: controller._enabled,
1159
+ builder: (context, enabled, child) {
1160
+ return Opacity (opacity: enabled ? 1 : 0.5 , child: child);
1161
+ },
1162
+ child: Row (children: composeButtons)),
1121
1163
buildSendButton (),
1122
1164
])),
1123
1165
]);
@@ -1180,10 +1222,14 @@ sealed class ComposeBoxController {
1180
1222
final content = ComposeContentController ();
1181
1223
final contentFocusNode = FocusNode ();
1182
1224
1225
+ bool get enabled => _enabled.value;
1226
+ final ValueNotifier <bool > _enabled = ValueNotifier <bool >(true );
1227
+
1183
1228
@mustCallSuper
1184
1229
void dispose () {
1185
1230
content.dispose ();
1186
1231
contentFocusNode.dispose ();
1232
+ _enabled.dispose ();
1187
1233
}
1188
1234
}
1189
1235
0 commit comments