Skip to content

Commit 1f49f51

Browse files
committed
compose_box: Implement most of compose box redesign
- We drop `_sendButtonSize` and `_inputVerticalPadding` because we no longer need them for setting the button's minHeight, along with `ButtonStyle` for the send button that was added in # 399, which is irrelevant to the new design. - `ClipRect`'s size is determined by the `ConstrainedBox`. This is mainly for showing the content through the `contentPadding` of the `TextField`, so that our `InsetShadowBox` can fade it smoothly there. The shadow is always there, but it is only visible when the `TextField` is long enough to be scrollable. - Getting rid of padding from `SafeArea` and `Padding` on `_ComposeBoxContainer` allows us to control the padding for topic/content inputs and buttons separately to match the new design. - For `InputDecorationTheme` on `_ComposeBoxLayout`, we eliminate `contentPadding`, while keeping `isDense` as `true` to explicitly remove paddings on the input widgets. - Note that we use `withFadedAlpha` on `designVariables.textInput` because the color is already transparent in dark mode, and the helper allows us to multiply, instead of to override, the alpha channel of the color with a factor. - DesignVariables.icon's value has been updated to match the current design. This would affect the appearance of the ChooseAccountPageOverflowButton on the choose account page, which is intentional. This is "most of" the redesign because the new button feedback is supported later. See also: - #928 (comment) - #928 (comment), which elaborates on the issue we intend to solve with the `ClipRect` setup. - https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3954-13395 - https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3862-14350 Signed-off-by: Zixuan James Li <[email protected]>
1 parent 2780d25 commit 1f49f51

File tree

3 files changed

+264
-109
lines changed

3 files changed

+264
-109
lines changed

lib/widgets/compose_box.dart

Lines changed: 145 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,16 @@ import '../model/compose.dart';
1414
import '../model/narrow.dart';
1515
import '../model/store.dart';
1616
import 'autocomplete.dart';
17+
import 'color.dart';
1718
import 'dialog.dart';
1819
import 'icons.dart';
20+
import 'inset_shadow.dart';
1921
import 'store.dart';
22+
import 'text.dart';
2023
import 'theme.dart';
2124

22-
const double _inputVerticalPadding = 8;
23-
const double _sendButtonSize = 36;
25+
const double _composeButtonWidth = 44;
26+
const double _composeButtonsRowHeight = 42;
2427

2528
/// A [TextEditingController] for use in the compose box.
2629
///
@@ -364,34 +367,76 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve
364367
}
365368
}
366369

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+
367395
@override
368396
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(
386410
controller: widget.controller,
387411
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,
390424
maxLines: null,
391425
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))))))));
395440
}
396441
}
397442

@@ -474,20 +519,32 @@ class _TopicInput extends StatelessWidget {
474519
@override
475520
Widget build(BuildContext context) {
476521
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));
478528

479529
return TopicAutocomplete(
480530
streamId: streamId,
481531
controller: controller,
482532
focusNode: focusNode,
483533
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))))));
491548
}
492549
}
493550

@@ -660,12 +717,14 @@ abstract class _AttachUploadsButton extends StatelessWidget {
660717

661718
@override
662719
Widget build(BuildContext context) {
663-
ColorScheme colorScheme = Theme.of(context).colorScheme;
720+
final designVariables = DesignVariables.of(context);
664721
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)));
669728
}
670729
}
671730

@@ -929,38 +988,22 @@ class _SendButtonState extends State<_SendButton> {
929988

930989
@override
931990
Widget build(BuildContext context) {
932-
final disabled = _hasValidationErrors;
933-
final colorScheme = Theme.of(context).colorScheme;
991+
final designVariables = DesignVariables.of(context);
934992
final zulipLocalizations = ZulipLocalizations.of(context);
935993

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;
940997

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,
9511000
child: IconButton(
9521001
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),
9641007
onPressed: _send));
9651008
}
9661009
}
@@ -972,18 +1015,17 @@ class _ComposeBoxContainer extends StatelessWidget {
9721015

9731016
@override
9741017
Widget build(BuildContext context) {
975-
ColorScheme colorScheme = Theme.of(context).colorScheme;
1018+
final designVariables = DesignVariables.of(context);
9761019

9771020
// TODO(design): Maybe put a max width on the compose box, like we do on
9781021
// 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))),
9801025
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)));
9871029
}
9881030
}
9891031

@@ -1004,42 +1046,45 @@ class _ComposeBoxLayout extends StatelessWidget {
10041046

10051047
@override
10061048
Widget build(BuildContext context) {
1007-
ThemeData themeData = Theme.of(context);
1008-
ColorScheme colorScheme = themeData.colorScheme;
1049+
final themeData = Theme.of(context);
10091050

10101051
final inputThemeData = themeData.copyWith(
1011-
inputDecorationTheme: InputDecorationTheme(
1052+
inputDecorationTheme: const InputDecorationTheme(
10121053
// Both [contentPadding] and [isDense] combine to make the layout compact.
10131054
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));
10231057

10241058
return _ComposeBoxContainer(
10251059
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,
10341087
]))),
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-
]),
10431088
]));
10441089
}
10451090
}

0 commit comments

Comments
 (0)