Skip to content

Commit

Permalink
compose: Implement most of compose box redesign
Browse files Browse the repository at this point in the history
- 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]>
  • Loading branch information
PIG208 committed Nov 15, 2024
1 parent 5956528 commit ebcc437
Show file tree
Hide file tree
Showing 3 changed files with 228 additions and 104 deletions.
231 changes: 134 additions & 97 deletions lib/widgets/compose_box.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ import '../model/compose.dart';
import '../model/narrow.dart';
import '../model/store.dart';
import 'autocomplete.dart';
import 'color.dart';
import 'dialog.dart';
import 'icons.dart';
import 'inset_shadow.dart';
import 'store.dart';
import 'text.dart';
import 'theme.dart';

const double _inputVerticalPadding = 8;
const double _sendButtonSize = 36;
const double _composeButtonSize = 44;

/// A [TextEditingController] for use in the compose box.
///
Expand Down Expand Up @@ -364,34 +366,77 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve
}
}

static double maxHeight(BuildContext context) {
final clampingTextScaler = MediaQuery.textScalerOf(context)
.clamp(maxScaleFactor: 1.5);
final scaledLineHeight = clampingTextScaler.scale(_fontSize) * _lineHeightRatio;

// Reserve space to fully show the first 7th lines and just partially
// clip the 8th line, where the height matches the spec at
// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3960-5147&node-type=text&m=dev
// > Maximum size of the compose box is suggested to be 178px. Which
// > has 7 fully visible lines of text
//
// The partial line hints that the content input is scrollable.
//
// Using the ambient TextScale means this works for different values of the
// system text-size setting. We clamp to a max scale factor to limit
// how tall the content input can get; that's to save room for the message
// list. The user can still scroll the input to see everything.
return _verticalPadding + 7.727 * scaledLineHeight;
}

static const _verticalPadding = 8.0;
static const _fontSize = 17.0;
static const _lineHeight = 22.0;
static const _lineHeightRatio = _lineHeight / _fontSize;

@override
Widget build(BuildContext context) {
ColorScheme colorScheme = Theme.of(context).colorScheme;

return InputDecorator(
decoration: const InputDecoration(),
child: ConstrainedBox(
constraints: const BoxConstraints(
minHeight: _sendButtonSize - 2 * _inputVerticalPadding,

// TODO constrain this adaptively (i.e. not hard-coded 200)
maxHeight: 200,
),
child: ComposeAutocomplete(
narrow: widget.narrow,
controller: widget.controller,
focusNode: widget.focusNode,
fieldViewBuilder: (context) {
return TextField(
final designVariables = DesignVariables.of(context);

return ComposeAutocomplete(
narrow: widget.narrow,
controller: widget.controller,
focusNode: widget.focusNode,
fieldViewBuilder: (context) => ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxHeight(context)),
// This [ClipRect] replaces the [TextField] clipping we disable below.
child: ClipRect(
child: InsetShadowBox(
top: _verticalPadding, bottom: _verticalPadding,
color: designVariables.composeBoxBg,
child: TextField(
controller: widget.controller,
focusNode: widget.focusNode,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration.collapsed(hintText: widget.hintText),
// Let the content show through the `contentPadding` so that
// our [InsetShadowBox] can fade it smoothly there.
clipBehavior: Clip.none,
style: TextStyle(
fontSize: _fontSize,
height: _lineHeightRatio,
color: designVariables.textInput),
// From the spec at
// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3960-5147&node-type=text&m=dev
// > Compose box has the height to fit 2 lines. This is [done] to
// > have a bigger hit area for the user to start the input. […]
minLines: 2,
maxLines: null,
textCapitalization: TextCapitalization.sentences,
);
}),
));
decoration: InputDecoration(
// This padding ensures that the user can always scroll long
// content entirely out of the top or bottom shadow if desired.
// With this and the `minLines: 2` above, an empty content input
// gets 60px vertical distance (with no text-size scaling)
// between the top of the top shadow and the bottom of the
// bottom shadow. That's a bit more than the 54px given in the
// Figma, and we can revisit if needed, but it's tricky to get
// that 54px distance while also making the scrolling work like
// this and offering two lines of touchable area.
contentPadding: const EdgeInsets.symmetric(vertical: _verticalPadding),
hintText: widget.hintText,
hintStyle: TextStyle(
color: designVariables.textInput.withFadedAlpha(0.5))))))));
}
}

Expand Down Expand Up @@ -474,20 +519,32 @@ class _TopicInput extends StatelessWidget {
@override
Widget build(BuildContext context) {
final zulipLocalizations = ZulipLocalizations.of(context);
ColorScheme colorScheme = Theme.of(context).colorScheme;
final designVariables = DesignVariables.of(context);
TextStyle topicTextStyle = TextStyle(
fontSize: 20,
height: 22 / 20,
color: designVariables.textInput.withFadedAlpha(0.9),
).merge(weightVariableTextStyle(context, wght: 600));

return TopicAutocomplete(
streamId: streamId,
controller: controller,
focusNode: focusNode,
contentFocusNode: contentFocusNode,
fieldViewBuilder: (context) => TextField(
controller: controller,
focusNode: focusNode,
textInputAction: TextInputAction.next,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(hintText: zulipLocalizations.composeBoxTopicHintText),
));
fieldViewBuilder: (context) => Container(
padding: const EdgeInsets.only(top: 10, bottom: 9),
decoration: BoxDecoration(border: Border(bottom: BorderSide(
width: 1,
color: designVariables.foreground.withFadedAlpha(0.2)))),
child: TextField(
controller: controller,
focusNode: focusNode,
textInputAction: TextInputAction.next,
style: topicTextStyle,
decoration: InputDecoration(
hintText: zulipLocalizations.composeBoxTopicHintText,
hintStyle: topicTextStyle.copyWith(
color: designVariables.textInput.withFadedAlpha(0.5))))));
}
}

Expand Down Expand Up @@ -660,12 +717,14 @@ abstract class _AttachUploadsButton extends StatelessWidget {

@override
Widget build(BuildContext context) {
ColorScheme colorScheme = Theme.of(context).colorScheme;
final designVariables = DesignVariables.of(context);
final zulipLocalizations = ZulipLocalizations.of(context);
return IconButton(
icon: Icon(icon, color: colorScheme.onSurfaceVariant),
tooltip: tooltip(zulipLocalizations),
onPressed: () => _handlePress(context));
return SizedBox(
width: _composeButtonSize,
child: IconButton(
icon: Icon(icon, color: designVariables.foreground.withFadedAlpha(0.5)),
tooltip: tooltip(zulipLocalizations),
onPressed: () => _handlePress(context)));
}
}

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

@override
Widget build(BuildContext context) {
final disabled = _hasValidationErrors;
final colorScheme = Theme.of(context).colorScheme;
final designVariables = DesignVariables.of(context);
final zulipLocalizations = ZulipLocalizations.of(context);

// Copy FilledButton defaults (_FilledButtonDefaultsM3.backgroundColor)
final backgroundColor = disabled
? colorScheme.onSurface.withValues(alpha: 0.12)
: colorScheme.primary;
final iconColor = _hasValidationErrors
? designVariables.icon.withFadedAlpha(0.5)
: designVariables.icon;

// Copy FilledButton defaults (_FilledButtonDefaultsM3.foregroundColor)
final foregroundColor = disabled
? colorScheme.onSurface.withValues(alpha: 0.38)
: colorScheme.onPrimary;

return Ink(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
color: backgroundColor,
),
return SizedBox(
width: _composeButtonSize,
child: IconButton(
tooltip: zulipLocalizations.composeBoxSendTooltip,
style: const ButtonStyle(
// Match the height of the content input.
minimumSize: WidgetStatePropertyAll(Size.square(_sendButtonSize)),
// With the default of [MaterialTapTargetSize.padded], not just the
// tap target but the visual button would get padded to 48px square.
// It would be nice if the tap target extended invisibly out from the
// button, to make a 48px square, but that's not the behavior we get.
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
color: foregroundColor,
icon: const Icon(ZulipIcons.send),
icon: Icon(ZulipIcons.send,
// We set [Icon.color] instead of [IconButton.color] because the
// latter implicitly uses colors derived from it to override the
// ambient [ButtonStyle.overlayColor].
color: iconColor),
onPressed: _send));
}
}
Expand All @@ -972,18 +1015,17 @@ class _ComposeBoxContainer extends StatelessWidget {

@override
Widget build(BuildContext context) {
ColorScheme colorScheme = Theme.of(context).colorScheme;
final designVariables = DesignVariables.of(context);

// TODO(design): Maybe put a max width on the compose box, like we do on
// the message list itself
return SizedBox(width: double.infinity,
return Container(width: double.infinity,
decoration: BoxDecoration(
border: Border(top: BorderSide(color: designVariables.borderBar))),
child: Material(
color: colorScheme.surfaceContainerHighest,
child: SafeArea(
minimum: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: child))));
color: designVariables.composeBoxBg,
child: SafeArea(minimum: const EdgeInsets.symmetric(horizontal: 8),
child: child)));
}
}

Expand All @@ -1004,22 +1046,14 @@ class _ComposeBoxLayout extends StatelessWidget {

@override
Widget build(BuildContext context) {
ThemeData themeData = Theme.of(context);
ColorScheme colorScheme = themeData.colorScheme;
final themeData = Theme.of(context);

final inputThemeData = themeData.copyWith(
inputDecorationTheme: InputDecorationTheme(
inputDecorationTheme: const InputDecorationTheme(
// Both [contentPadding] and [isDense] combine to make the layout compact.
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12.0, vertical: _inputVerticalPadding),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(4.0)),
borderSide: BorderSide.none),
filled: true,
fillColor: colorScheme.surface,
),
);
contentPadding: EdgeInsets.zero,
border: InputBorder.none));

final composeButtons = [
_AttachFileButton(contentController: contentController, contentFocusNode: contentFocusNode),
Expand All @@ -1029,19 +1063,22 @@ class _ComposeBoxLayout extends StatelessWidget {

return _ComposeBoxContainer(
child: Column(children: [
Row(crossAxisAlignment: CrossAxisAlignment.end, children: [
Expanded(
child: Theme(
data: inputThemeData,
child: Column(children: [
if (topicInput != null) topicInput!,
if (topicInput != null) const SizedBox(height: 8),
contentInput,
]))),
const SizedBox(width: 8),
sendButton,
]),
Row(children: composeButtons),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Theme(
data: inputThemeData,
child: Column(children: [
if (topicInput != null) topicInput!,
contentInput,
]))),
SizedBox(
height: _composeButtonSize,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(children: composeButtons),
sendButton,
])),
]));
}
}
Expand Down
Loading

0 comments on commit ebcc437

Please sign in to comment.