Skip to content

Commit 445a080

Browse files
committed
compose_box: Implement input redesign
The bottom underline implementation draws similarity to that of `Spoiler`'s. We could have used `UnderlineInputBorder` on `InputDecoration`, but that also comes with other input state styles that we do not need (e.g.: focused, disabled, etc.). 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. `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. See also: - zulip#928 (comment) - zulip#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 Fixes: zulip#915 Signed-off-by: Zixuan James Li <[email protected]>
1 parent 2e4ce98 commit 445a080

File tree

2 files changed

+100
-28
lines changed

2 files changed

+100
-28
lines changed

lib/widgets/compose_box.dart

+93-28
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ import 'autocomplete.dart';
1717
import 'color.dart';
1818
import 'dialog.dart';
1919
import 'icons.dart';
20+
import 'inset_shadow.dart';
2021
import 'store.dart';
22+
import 'text.dart';
2123
import 'theme.dart';
2224

2325
const double _composeButtonWidth = 44;
@@ -365,32 +367,76 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve
365367
}
366368
}
367369

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+
368395
@override
369396
Widget build(BuildContext context) {
370-
ColorScheme colorScheme = Theme.of(context).colorScheme;
371-
372-
return InputDecorator(
373-
decoration: const InputDecoration(),
374-
child: ConstrainedBox(
375-
constraints: const BoxConstraints(
376-
// TODO constrain this adaptively (i.e. not hard-coded 200)
377-
maxHeight: 200,
378-
),
379-
child: ComposeAutocomplete(
380-
narrow: widget.narrow,
381-
controller: widget.controller,
382-
focusNode: widget.focusNode,
383-
fieldViewBuilder: (context) {
384-
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(
385410
controller: widget.controller,
386411
focusNode: widget.focusNode,
387-
style: TextStyle(color: colorScheme.onSurface),
388-
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,
389424
maxLines: null,
390425
textCapitalization: TextCapitalization.sentences,
391-
);
392-
}),
393-
));
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))))))));
394440
}
395441
}
396442

@@ -473,20 +519,39 @@ class _TopicInput extends StatelessWidget {
473519
@override
474520
Widget build(BuildContext context) {
475521
final zulipLocalizations = ZulipLocalizations.of(context);
476-
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));
477528

478529
return TopicAutocomplete(
479530
streamId: streamId,
480531
controller: controller,
481532
focusNode: focusNode,
482533
contentFocusNode: contentFocusNode,
483-
fieldViewBuilder: (context) => TextField(
484-
controller: controller,
485-
focusNode: focusNode,
486-
textInputAction: TextInputAction.next,
487-
style: TextStyle(color: colorScheme.onSurface),
488-
decoration: InputDecoration(hintText: zulipLocalizations.composeBoxTopicHintText),
489-
));
534+
fieldViewBuilder: (context) => Column(
535+
mainAxisSize: MainAxisSize.min,
536+
children: [
537+
Padding(
538+
padding: const EdgeInsets.symmetric(vertical: 10),
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))))),
548+
SizedBox(height: 0, width: double.infinity,
549+
child: DecoratedBox(decoration: BoxDecoration(
550+
border: Border(
551+
bottom: BorderSide(
552+
width: 1,
553+
color: designVariables.foreground.withFadedAlpha(0.2)))))),
554+
]));
490555
}
491556
}
492557

lib/widgets/theme.dart

+7
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
129129
labelEdited: const HSLColor.fromAHSL(0.35, 0, 0, 0).toColor(),
130130
labelMenuButton: const Color(0xff222222),
131131
mainBackground: const Color(0xfff0f0f0),
132+
textInput: const Color(0xff000000),
132133
title: const Color(0xff1a1a1a),
133134
channelColorSwatches: ChannelColorSwatches.light,
134135
atMentionMarker: const HSLColor.fromAHSL(0.5, 0, 0, 0.2).toColor(),
@@ -168,6 +169,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
168169
labelEdited: const HSLColor.fromAHSL(0.35, 0, 0, 1).toColor(),
169170
labelMenuButton: const Color(0xffffffff).withValues(alpha: 0.85),
170171
mainBackground: const Color(0xff1d1d1d),
172+
textInput: const Color(0xffffffff).withValues(alpha: 0.9),
171173
title: const Color(0xffffffff),
172174
channelColorSwatches: ChannelColorSwatches.dark,
173175
contextMenuCancelBg: const Color(0xff797986), // the same as the light mode in Figma
@@ -214,6 +216,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
214216
required this.labelEdited,
215217
required this.labelMenuButton,
216218
required this.mainBackground,
219+
required this.textInput,
217220
required this.title,
218221
required this.channelColorSwatches,
219222
required this.atMentionMarker,
@@ -261,6 +264,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
261264
final Color labelEdited;
262265
final Color labelMenuButton;
263266
final Color mainBackground;
267+
final Color textInput;
264268
final Color title;
265269

266270
// Not exactly from the Figma design, but from Vlad anyway.
@@ -303,6 +307,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
303307
Color? labelEdited,
304308
Color? labelMenuButton,
305309
Color? mainBackground,
310+
Color? textInput,
306311
Color? title,
307312
ChannelColorSwatches? channelColorSwatches,
308313
Color? atMentionMarker,
@@ -340,6 +345,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
340345
labelEdited: labelEdited ?? this.labelEdited,
341346
labelMenuButton: labelMenuButton ?? this.labelMenuButton,
342347
mainBackground: mainBackground ?? this.mainBackground,
348+
textInput: textInput ?? this.textInput,
343349
title: title ?? this.title,
344350
channelColorSwatches: channelColorSwatches ?? this.channelColorSwatches,
345351
atMentionMarker: atMentionMarker ?? this.atMentionMarker,
@@ -384,6 +390,7 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
384390
labelEdited: Color.lerp(labelEdited, other.labelEdited, t)!,
385391
labelMenuButton: Color.lerp(labelMenuButton, other.labelMenuButton, t)!,
386392
mainBackground: Color.lerp(mainBackground, other.mainBackground, t)!,
393+
textInput: Color.lerp(textInput, other.textInput, t)!,
387394
title: Color.lerp(title, other.title, t)!,
388395
channelColorSwatches: ChannelColorSwatches.lerp(channelColorSwatches, other.channelColorSwatches, t),
389396
atMentionMarker: Color.lerp(atMentionMarker, other.atMentionMarker, t)!,

0 commit comments

Comments
 (0)