Skip to content

Commit 3adb395

Browse files
committed
compose: 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. Discussion here: #928 (comment) - For `InputDecorationTheme` on `_ComposeBoxLayout`, we zero out `contentPadding` while keeping `isDense` as `true`, to explicitly remove paddings on the input widgets. - The height of the compose buttons is 42px in the Figma design, but 44px in the implementation. We change that to match the minimum button size per the accessibility recommendation from Apple. Discussion here: #928 (comment) - 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. Discussion here: #928 (comment) - 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. Design spec here: - 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 5956528 commit 3adb395

File tree

3 files changed

+228
-104
lines changed

3 files changed

+228
-104
lines changed

lib/widgets/compose_box.dart

Lines changed: 134 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@ 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 _composeButtonSize = 44;
2426

2527
/// A [TextEditingController] for use in the compose box.
2628
///
@@ -364,34 +366,77 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve
364366
}
365367
}
366368

369+
static double maxHeight(BuildContext context) {
370+
final clampingTextScaler = MediaQuery.textScalerOf(context)
371+
.clamp(maxScaleFactor: 1.5);
372+
final scaledLineHeight = clampingTextScaler.scale(_fontSize) * _lineHeightRatio;
373+
374+
// Reserve space to fully show the first 7th lines and just partially
375+
// clip the 8th line, where the height matches the spec at
376+
// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3960-5147&node-type=text&m=dev
377+
// > Maximum size of the compose box is suggested to be 178px. Which
378+
// > has 7 fully visible lines of text
379+
//
380+
// The partial line hints that the content input is scrollable.
381+
//
382+
// Using the ambient TextScale means this works for different values of the
383+
// system text-size setting. We clamp to a max scale factor to limit
384+
// how tall the content input can get; that's to save room for the message
385+
// list. The user can still scroll the input to see everything.
386+
return _verticalPadding + 7.727 * scaledLineHeight;
387+
}
388+
389+
static const _verticalPadding = 8.0;
390+
static const _fontSize = 17.0;
391+
static const _lineHeight = 22.0;
392+
static const _lineHeightRatio = _lineHeight / _fontSize;
393+
367394
@override
368395
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(
396+
final designVariables = DesignVariables.of(context);
397+
398+
return ComposeAutocomplete(
399+
narrow: widget.narrow,
400+
controller: widget.controller,
401+
focusNode: widget.focusNode,
402+
fieldViewBuilder: (context) => ConstrainedBox(
403+
constraints: BoxConstraints(maxHeight: maxHeight(context)),
404+
// This [ClipRect] replaces the [TextField] clipping we disable below.
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 (with no text-size scaling)
431+
// between the top of the top shadow and the bottom of the
432+
// bottom shadow. That's a bit more than the 54px given in the
433+
// Figma, and we can revisit if needed, but it's tricky to get
434+
// that 54px distance while also making the scrolling work like
435+
// this 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: _composeButtonSize,
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: _composeButtonSize,
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 [Icon.color] instead of [IconButton.color] because the
1004+
// latter implicitly uses colors derived from it to override the
1005+
// ambient [ButtonStyle.overlayColor].
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(minimum: const EdgeInsets.symmetric(horizontal: 8),
1028+
child: child)));
9871029
}
9881030
}
9891031

@@ -1004,22 +1046,14 @@ 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
final composeButtons = [
10251059
_AttachFileButton(contentController: contentController, contentFocusNode: contentFocusNode),
@@ -1029,19 +1063,22 @@ class _ComposeBoxLayout extends StatelessWidget {
10291063

10301064
return _ComposeBoxContainer(
10311065
child: Column(children: [
1032-
Row(crossAxisAlignment: CrossAxisAlignment.end, children: [
1033-
Expanded(
1034-
child: Theme(
1035-
data: inputThemeData,
1036-
child: Column(children: [
1037-
if (topicInput != null) topicInput!,
1038-
if (topicInput != null) const SizedBox(height: 8),
1039-
contentInput,
1040-
]))),
1041-
const SizedBox(width: 8),
1042-
sendButton,
1043-
]),
1044-
Row(children: composeButtons),
1066+
Padding(
1067+
padding: const EdgeInsets.symmetric(horizontal: 8),
1068+
child: Theme(
1069+
data: inputThemeData,
1070+
child: Column(children: [
1071+
if (topicInput != null) topicInput!,
1072+
contentInput,
1073+
]))),
1074+
SizedBox(
1075+
height: _composeButtonSize,
1076+
child: Row(
1077+
mainAxisAlignment: MainAxisAlignment.spaceBetween,
1078+
children: [
1079+
Row(children: composeButtons),
1080+
sendButton,
1081+
])),
10451082
]));
10461083
}
10471084
}

0 commit comments

Comments
 (0)