@@ -14,13 +14,16 @@ import '../model/compose.dart';
14
14
import '../model/narrow.dart' ;
15
15
import '../model/store.dart' ;
16
16
import 'autocomplete.dart' ;
17
+ import 'color.dart' ;
17
18
import 'dialog.dart' ;
18
19
import 'icons.dart' ;
20
+ import 'inset_shadow.dart' ;
19
21
import 'store.dart' ;
22
+ import 'text.dart' ;
20
23
import 'theme.dart' ;
21
24
22
- const double _inputVerticalPadding = 8 ;
23
- const double _sendButtonSize = 36 ;
25
+ const double _composeButtonWidth = 44 ;
26
+ const double _composeButtonsRowHeight = 42 ;
24
27
25
28
/// A [TextEditingController] for use in the compose box.
26
29
///
@@ -364,34 +367,76 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve
364
367
}
365
368
}
366
369
370
+ static double maxHeight (BuildContext context) {
371
+ final clampingTextScaler = MediaQuery .textScalerOf (context)
372
+ .clamp (maxScaleFactor: 1.5 );
373
+ final scaledLineHeight = clampingTextScaler.scale (fontSize) * lineHeightRatio;
374
+
375
+ // Reserve space to fully show the first 7th lines and just partially
376
+ // clip the 8th line, where the height matches the spec at
377
+ // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3960-5147&node-type=text&m=dev
378
+ // > Maximum size of the compose box is suggested to be 178px. Which
379
+ // > has 7 fully visible lines of text
380
+ //
381
+ // The partial line hints that the content input is scrollable.
382
+ //
383
+ // Using the ambient TextScale means this works for different values of the
384
+ // system text-size setting. We clamp to a max scale factor to limit
385
+ // how tall the content input can get; that's to save room for the message
386
+ // list. The user can still scroll the input to see everything.
387
+ return verticalPadding + 7.727 * scaledLineHeight;
388
+ }
389
+
390
+ static const verticalPadding = 8.0 ;
391
+ static const fontSize = 17.0 ;
392
+ static const lineHeight = 22.0 ;
393
+ static const lineHeightRatio = lineHeight / fontSize;
394
+
367
395
@override
368
396
Widget build (BuildContext context) {
369
- ColorScheme colorScheme = Theme .of (context).colorScheme;
370
-
371
- return InputDecorator (
372
- decoration: const InputDecoration (),
373
- child: ConstrainedBox (
374
- constraints: const BoxConstraints (
375
- minHeight: _sendButtonSize - 2 * _inputVerticalPadding,
376
-
377
- // TODO constrain this adaptively (i.e. not hard-coded 200)
378
- maxHeight: 200 ,
379
- ),
380
- child: ComposeAutocomplete (
381
- narrow: widget.narrow,
382
- controller: widget.controller,
383
- focusNode: widget.focusNode,
384
- fieldViewBuilder: (context) {
385
- return TextField (
397
+ final designVariables = DesignVariables .of (context);
398
+
399
+ return ComposeAutocomplete (
400
+ narrow: widget.narrow,
401
+ controller: widget.controller,
402
+ focusNode: widget.focusNode,
403
+ fieldViewBuilder: (context) => ConstrainedBox (
404
+ constraints: BoxConstraints (maxHeight: maxHeight (context)),
405
+ child: ClipRect (
406
+ child: InsetShadowBox (
407
+ top: verticalPadding, bottom: verticalPadding,
408
+ color: designVariables.composeBoxBg,
409
+ child: TextField (
386
410
controller: widget.controller,
387
411
focusNode: widget.focusNode,
388
- style: TextStyle (color: colorScheme.onSurface),
389
- decoration: InputDecoration .collapsed (hintText: widget.hintText),
412
+ // Let the content show through the `contentPadding` so that
413
+ // our [InsetShadowBox] can fade it smoothly there.
414
+ clipBehavior: Clip .none,
415
+ style: TextStyle (
416
+ fontSize: fontSize,
417
+ height: lineHeightRatio,
418
+ color: designVariables.textInput),
419
+ // From the spec at
420
+ // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3960-5147&node-type=text&m=dev
421
+ // > Compose box has the height to fit 2 lines. This is [done] to
422
+ // > have a bigger hit area for the user to start the input. […]
423
+ minLines: 2 ,
390
424
maxLines: null ,
391
425
textCapitalization: TextCapitalization .sentences,
392
- );
393
- }),
394
- ));
426
+ decoration: InputDecoration (
427
+ // This padding ensures that the user can always scroll long
428
+ // content entirely out of the top or bottom shadow if desired.
429
+ // With this and the `minLines: 2` above, an empty content input
430
+ // gets 60px vertical distance between the top of the top shadow
431
+ // and the bottom of the bottom shadow (with no text-size
432
+ // scaling). That's a bit more than the 54px given in the Figma,
433
+ // and we can revisit if needed, but it's tricky to get that
434
+ // 54px distance while also making the scrolling work like this
435
+ // and offering two lines of touchable area.
436
+ contentPadding: const EdgeInsets .symmetric (vertical: verticalPadding),
437
+ hintText: widget.hintText,
438
+ hintStyle: TextStyle (
439
+ color: designVariables.textInput.withFadedAlpha (0.5 ))))))));
395
440
}
396
441
}
397
442
@@ -474,20 +519,32 @@ class _TopicInput extends StatelessWidget {
474
519
@override
475
520
Widget build (BuildContext context) {
476
521
final zulipLocalizations = ZulipLocalizations .of (context);
477
- ColorScheme colorScheme = Theme .of (context).colorScheme;
522
+ final designVariables = DesignVariables .of (context);
523
+ TextStyle topicTextStyle = TextStyle (
524
+ fontSize: 20 ,
525
+ height: 22 / 20 ,
526
+ color: designVariables.textInput.withFadedAlpha (0.9 ),
527
+ ).merge (weightVariableTextStyle (context, wght: 600 ));
478
528
479
529
return TopicAutocomplete (
480
530
streamId: streamId,
481
531
controller: controller,
482
532
focusNode: focusNode,
483
533
contentFocusNode: contentFocusNode,
484
- fieldViewBuilder: (context) => TextField (
485
- controller: controller,
486
- focusNode: focusNode,
487
- textInputAction: TextInputAction .next,
488
- style: TextStyle (color: colorScheme.onSurface),
489
- decoration: InputDecoration (hintText: zulipLocalizations.composeBoxTopicHintText),
490
- ));
534
+ fieldViewBuilder: (context) => 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
+ controller: controller,
541
+ focusNode: focusNode,
542
+ textInputAction: TextInputAction .next,
543
+ style: topicTextStyle,
544
+ decoration: InputDecoration (
545
+ hintText: zulipLocalizations.composeBoxTopicHintText,
546
+ hintStyle: topicTextStyle.copyWith (
547
+ color: designVariables.textInput.withFadedAlpha (0.5 ))))));
491
548
}
492
549
}
493
550
@@ -660,12 +717,14 @@ abstract class _AttachUploadsButton extends StatelessWidget {
660
717
661
718
@override
662
719
Widget build (BuildContext context) {
663
- ColorScheme colorScheme = Theme .of (context).colorScheme ;
720
+ final designVariables = DesignVariables .of (context);
664
721
final zulipLocalizations = ZulipLocalizations .of (context);
665
- return IconButton (
666
- icon: Icon (icon, color: colorScheme.onSurfaceVariant),
667
- tooltip: tooltip (zulipLocalizations),
668
- onPressed: () => _handlePress (context));
722
+ return SizedBox (
723
+ width: _composeButtonWidth,
724
+ child: IconButton (
725
+ icon: Icon (icon, color: designVariables.foreground.withFadedAlpha (0.5 )),
726
+ tooltip: tooltip (zulipLocalizations),
727
+ onPressed: () => _handlePress (context)));
669
728
}
670
729
}
671
730
@@ -929,38 +988,22 @@ class _SendButtonState extends State<_SendButton> {
929
988
930
989
@override
931
990
Widget build (BuildContext context) {
932
- final disabled = _hasValidationErrors;
933
- final colorScheme = Theme .of (context).colorScheme;
991
+ final designVariables = DesignVariables .of (context);
934
992
final zulipLocalizations = ZulipLocalizations .of (context);
935
993
936
- // Copy FilledButton defaults (_FilledButtonDefaultsM3.backgroundColor)
937
- final backgroundColor = disabled
938
- ? colorScheme.onSurface.withValues (alpha: 0.12 )
939
- : colorScheme.primary;
994
+ final iconColor = _hasValidationErrors
995
+ ? designVariables.icon.withFadedAlpha (0.5 )
996
+ : designVariables.icon;
940
997
941
- // Copy FilledButton defaults (_FilledButtonDefaultsM3.foregroundColor)
942
- final foregroundColor = disabled
943
- ? colorScheme.onSurface.withValues (alpha: 0.38 )
944
- : colorScheme.onPrimary;
945
-
946
- return Ink (
947
- decoration: BoxDecoration (
948
- borderRadius: const BorderRadius .all (Radius .circular (8.0 )),
949
- color: backgroundColor,
950
- ),
998
+ return SizedBox (
999
+ width: _composeButtonWidth,
951
1000
child: IconButton (
952
1001
tooltip: zulipLocalizations.composeBoxSendTooltip,
953
- style: const ButtonStyle (
954
- // Match the height of the content input.
955
- minimumSize: WidgetStatePropertyAll (Size .square (_sendButtonSize)),
956
- // With the default of [MaterialTapTargetSize.padded], not just the
957
- // tap target but the visual button would get padded to 48px square.
958
- // It would be nice if the tap target extended invisibly out from the
959
- // button, to make a 48px square, but that's not the behavior we get.
960
- tapTargetSize: MaterialTapTargetSize .shrinkWrap,
961
- ),
962
- color: foregroundColor,
963
- icon: const Icon (ZulipIcons .send),
1002
+ icon: Icon (ZulipIcons .send,
1003
+ // We set the color on `Icon` instead of `IconButton` because the
1004
+ // Figma design does not apply it on the background of the button
1005
+ // for states like highlighted, etc.
1006
+ color: iconColor),
964
1007
onPressed: _send));
965
1008
}
966
1009
}
@@ -972,18 +1015,17 @@ class _ComposeBoxContainer extends StatelessWidget {
972
1015
973
1016
@override
974
1017
Widget build (BuildContext context) {
975
- ColorScheme colorScheme = Theme .of (context).colorScheme ;
1018
+ final designVariables = DesignVariables .of (context);
976
1019
977
1020
// TODO(design): Maybe put a max width on the compose box, like we do on
978
1021
// the message list itself
979
- return SizedBox (width: double .infinity,
1022
+ return Container (width: double .infinity,
1023
+ decoration: BoxDecoration (
1024
+ border: Border (top: BorderSide (color: designVariables.borderBar))),
980
1025
child: Material (
981
- color: colorScheme.surfaceContainerHighest,
982
- child: SafeArea (
983
- minimum: const EdgeInsets .fromLTRB (8 , 0 , 8 , 8 ),
984
- child: Padding (
985
- padding: const EdgeInsets .only (top: 8.0 ),
986
- child: child))));
1026
+ color: designVariables.composeBoxBg,
1027
+ child: SafeArea (left: false , right: false ,
1028
+ child: child)));
987
1029
}
988
1030
}
989
1031
@@ -1004,42 +1046,45 @@ class _ComposeBoxLayout extends StatelessWidget {
1004
1046
1005
1047
@override
1006
1048
Widget build (BuildContext context) {
1007
- ThemeData themeData = Theme .of (context);
1008
- ColorScheme colorScheme = themeData.colorScheme;
1049
+ final themeData = Theme .of (context);
1009
1050
1010
1051
final inputThemeData = themeData.copyWith (
1011
- inputDecorationTheme: InputDecorationTheme (
1052
+ inputDecorationTheme: const InputDecorationTheme (
1012
1053
// Both [contentPadding] and [isDense] combine to make the layout compact.
1013
1054
isDense: true ,
1014
- contentPadding: const EdgeInsets .symmetric (
1015
- horizontal: 12.0 , vertical: _inputVerticalPadding),
1016
- border: const OutlineInputBorder (
1017
- borderRadius: BorderRadius .all (Radius .circular (4.0 )),
1018
- borderSide: BorderSide .none),
1019
- filled: true ,
1020
- fillColor: colorScheme.surface,
1021
- ),
1022
- );
1055
+ contentPadding: EdgeInsets .zero,
1056
+ border: InputBorder .none));
1023
1057
1024
1058
return _ComposeBoxContainer (
1025
1059
child: Column (children: [
1026
- Row (crossAxisAlignment: CrossAxisAlignment .end, children: [
1027
- Expanded (
1028
- child: Theme (
1029
- data: inputThemeData,
1030
- child: Column (children: [
1031
- if (topicInput != null ) topicInput! ,
1032
- if (topicInput != null ) const SizedBox (height: 8 ),
1033
- contentInput,
1060
+ SafeArea (
1061
+ minimum: const EdgeInsets .symmetric (horizontal: 16 ),
1062
+ child: Theme (
1063
+ data: inputThemeData,
1064
+ child: Column (children: [
1065
+ if (topicInput != null ) topicInput! ,
1066
+ contentInput,
1067
+ ]))),
1068
+ SafeArea (
1069
+ minimum: const EdgeInsets .symmetric (horizontal: 8 ),
1070
+ child: SizedBox (
1071
+ height: _composeButtonsRowHeight,
1072
+ child: Row (
1073
+ mainAxisAlignment: MainAxisAlignment .spaceBetween,
1074
+ children: [
1075
+ Row (children: [
1076
+ _AttachFileButton (
1077
+ contentController: contentController,
1078
+ contentFocusNode: contentFocusNode),
1079
+ _AttachMediaButton (
1080
+ contentController: contentController,
1081
+ contentFocusNode: contentFocusNode),
1082
+ _AttachFromCameraButton (
1083
+ contentController: contentController,
1084
+ contentFocusNode: contentFocusNode),
1085
+ ]),
1086
+ sendButton,
1034
1087
]))),
1035
- const SizedBox (width: 8 ),
1036
- sendButton,
1037
- ]),
1038
- Row (children: [
1039
- _AttachFileButton (contentController: contentController, contentFocusNode: contentFocusNode),
1040
- _AttachMediaButton (contentController: contentController, contentFocusNode: contentFocusNode),
1041
- _AttachFromCameraButton (contentController: contentController, contentFocusNode: contentFocusNode),
1042
- ]),
1043
1088
]));
1044
1089
}
1045
1090
}
0 commit comments