diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 0f902b96fc..be5225e5a1 100644 Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ diff --git a/assets/icons/attach_file.svg b/assets/icons/attach_file.svg new file mode 100644 index 0000000000..1976e8fb06 --- /dev/null +++ b/assets/icons/attach_file.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/camera.svg b/assets/icons/camera.svg new file mode 100644 index 0000000000..5e54c454de --- /dev/null +++ b/assets/icons/camera.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/image.svg b/assets/icons/image.svg new file mode 100644 index 0000000000..d63d12ac5a --- /dev/null +++ b/assets/icons/image.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/send.svg b/assets/icons/send.svg new file mode 100644 index 0000000000..4d87a3e446 --- /dev/null +++ b/assets/icons/send.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 844f820c13..d90f5f0c5f 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -13,6 +13,7 @@ import '../model/internal_link.dart'; import '../model/narrow.dart'; import 'actions.dart'; import 'clipboard.dart'; +import 'color.dart'; import 'dialog.dart'; import 'icons.dart'; import 'inset_shadow.dart'; @@ -145,8 +146,8 @@ abstract class MessageActionSheetMenuItemButton extends StatelessWidget { foregroundColor: designVariables.contextMenuItemText, splashFactory: NoSplash.splashFactory, ).copyWith(backgroundColor: WidgetStateColor.resolveWith((states) => - designVariables.contextMenuItemBg.withValues( - alpha: states.contains(WidgetState.pressed) ? 0.20 : 0.12))), + designVariables.contextMenuItemBg.withFadedAlpha( + states.contains(WidgetState.pressed) ? 0.20 : 0.12))), onPressed: () => _handlePressed(context), child: Text(label(zulipLocalizations), style: const TextStyle(fontSize: 20, height: 24 / 20) @@ -168,8 +169,8 @@ class MessageActionSheetCancelButton extends StatelessWidget { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(7)), splashFactory: NoSplash.splashFactory, ).copyWith(backgroundColor: WidgetStateColor.resolveWith((states) => - designVariables.contextMenuCancelBg.withValues( - alpha: states.contains(WidgetState.pressed) ? 0.20 : 0.15))), + designVariables.contextMenuCancelBg.withFadedAlpha( + states.contains(WidgetState.pressed) ? 0.20 : 0.15))), onPressed: () { Navigator.pop(context); }, diff --git a/lib/widgets/color.dart b/lib/widgets/color.dart index 59c75826ba..4aff79ee16 100644 --- a/lib/widgets/color.dart +++ b/lib/widgets/color.dart @@ -38,4 +38,21 @@ extension ColorExtension on Color { ((g * 255.0).round() & 0xff) << 8 | ((b * 255.0).round() & 0xff) << 0; } + + /// Makes a copy of this color with [a] multiplied by `factor`. + /// + /// `factor` must be between 0 and 1, inclusive. + /// + /// To fade a color variable from [DesignVariables], [ContentTheme], etc., + /// use this instead of calling [withValues] with `factor` passed as `alpha`, + /// which simply replaces the color's [a] instead of multiplying by it. + /// Using [withValues] gives the same result for an opaque color, + /// but a wrong result for a semi-transparent color, + /// and we want our color variables to be free to change + /// without breaking things. + Color withFadedAlpha(double factor) { + assert(factor >= 0); + assert(factor <= 1); + return withValues(alpha: a * factor); + } } diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 2060f420da..b6f187921b 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -14,12 +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. /// @@ -363,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)))))))); } } @@ -473,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)))))); } } @@ -659,11 +717,14 @@ abstract class _AttachUploadsButton extends StatelessWidget { @override Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); final zulipLocalizations = ZulipLocalizations.of(context); - return IconButton( - icon: Icon(icon), - 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))); } } @@ -725,7 +786,7 @@ class _AttachFileButton extends _AttachUploadsButton { const _AttachFileButton({required super.contentController, required super.contentFocusNode}); @override - IconData get icon => Icons.attach_file; + IconData get icon => ZulipIcons.attach_file; @override String tooltip(ZulipLocalizations zulipLocalizations) => @@ -741,7 +802,7 @@ class _AttachMediaButton extends _AttachUploadsButton { const _AttachMediaButton({required super.contentController, required super.contentFocusNode}); @override - IconData get icon => Icons.image; + IconData get icon => ZulipIcons.image; @override String tooltip(ZulipLocalizations zulipLocalizations) => @@ -758,7 +819,7 @@ class _AttachFromCameraButton extends _AttachUploadsButton { const _AttachFromCameraButton({required super.contentController, required super.contentFocusNode}); @override - IconData get icon => Icons.camera_alt; + IconData get icon => ZulipIcons.camera; @override String tooltip(ZulipLocalizations zulipLocalizations) => @@ -927,38 +988,23 @@ 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; - - // Copy FilledButton defaults (_FilledButtonDefaultsM3.foregroundColor) - final foregroundColor = disabled - ? colorScheme.onSurface.withValues(alpha: 0.38) - : colorScheme.onPrimary; + final iconColor = _hasValidationErrors + ? designVariables.icon.withFadedAlpha(0.5) + : designVariables.icon; - 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(Icons.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], where we set the color for + // the highlight state to match the Figma design. + color: iconColor), onPressed: _send)); } } @@ -970,18 +1016,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))); } } @@ -1002,45 +1047,54 @@ class _ComposeBoxLayout extends StatelessWidget { @override Widget build(BuildContext context) { - ThemeData themeData = Theme.of(context); - ColorScheme colorScheme = themeData.colorScheme; + final themeData = Theme.of(context); + final designVariables = DesignVariables.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)); + + // TODO(#417): Disable splash effects for all buttons globally. + final iconButtonThemeData = IconButtonThemeData( + style: IconButton.styleFrom( + splashFactory: NoSplash.splashFactory, + // TODO(#417): The Figma design specifies a different icon color on + // pressed, but `IconButton` currently does not have support for + // that. See also: + // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3707-41711&node-type=frame&t=sSYomsJzGCt34D8N-0 + highlightColor: designVariables.editorButtonPressedBg, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(4))))); + + final composeButtons = [ + _AttachFileButton(contentController: contentController, contentFocusNode: contentFocusNode), + _AttachMediaButton(contentController: contentController, contentFocusNode: contentFocusNode), + _AttachFromCameraButton(contentController: contentController, contentFocusNode: contentFocusNode), + ]; 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, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Theme( + data: inputThemeData, + child: Column(children: [ + if (topicInput != null) topicInput!, + contentInput, + ]))), + SizedBox( + height: _composeButtonSize, + child: IconButtonTheme( + data: iconButtonThemeData, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row(children: composeButtons), + sendButton, ]))), - const SizedBox(width: 8), - sendButton, - ]), - Theme( - data: themeData.copyWith( - iconTheme: themeData.iconTheme.copyWith(color: colorScheme.onSurfaceVariant)), - child: Row(children: [ - _AttachFileButton(contentController: contentController, contentFocusNode: contentFocusNode), - _AttachMediaButton(contentController: contentController, contentFocusNode: contentFocusNode), - _AttachFromCameraButton(contentController: contentController, contentFocusNode: contentFocusNode), - ])), ])); } } diff --git a/lib/widgets/emoji_reaction.dart b/lib/widgets/emoji_reaction.dart index 3d87b7a8a0..d92a050b05 100644 --- a/lib/widgets/emoji_reaction.dart +++ b/lib/widgets/emoji_reaction.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import '../api/model/model.dart'; import '../api/route/messages.dart'; import '../model/emoji.dart'; +import 'color.dart'; import 'content.dart'; import 'store.dart'; import 'text.dart'; @@ -166,7 +167,7 @@ class ReactionChip extends StatelessWidget { final labelColor = selfVoted ? reactionTheme.textSelected : reactionTheme.textUnselected; final backgroundColor = selfVoted ? reactionTheme.bgSelected : reactionTheme.bgUnselected; final splashColor = selfVoted ? reactionTheme.bgUnselected : reactionTheme.bgSelected; - final highlightColor = splashColor.withValues(alpha: 0.5); + final highlightColor = splashColor.withFadedAlpha(0.5); final borderSide = BorderSide( color: borderColor, diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index ebdb6a362c..03356448ef 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -33,65 +33,77 @@ abstract final class ZulipIcons { /// The Zulip custom icon "at_sign". static const IconData at_sign = IconData(0xf103, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "attach_file". + static const IconData attach_file = IconData(0xf104, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "bot". - static const IconData bot = IconData(0xf104, fontFamily: "Zulip Icons"); + static const IconData bot = IconData(0xf105, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "camera". + static const IconData camera = IconData(0xf106, fontFamily: "Zulip Icons"); /// The Zulip custom icon "chevron_right". - static const IconData chevron_right = IconData(0xf105, fontFamily: "Zulip Icons"); + static const IconData chevron_right = IconData(0xf107, fontFamily: "Zulip Icons"); /// The Zulip custom icon "clock". - static const IconData clock = IconData(0xf106, fontFamily: "Zulip Icons"); + static const IconData clock = IconData(0xf108, fontFamily: "Zulip Icons"); /// The Zulip custom icon "copy". - static const IconData copy = IconData(0xf107, fontFamily: "Zulip Icons"); + static const IconData copy = IconData(0xf109, fontFamily: "Zulip Icons"); /// The Zulip custom icon "format_quote". - static const IconData format_quote = IconData(0xf108, fontFamily: "Zulip Icons"); + static const IconData format_quote = IconData(0xf10a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "globe". - static const IconData globe = IconData(0xf109, fontFamily: "Zulip Icons"); + static const IconData globe = IconData(0xf10b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "group_dm". - static const IconData group_dm = IconData(0xf10a, fontFamily: "Zulip Icons"); + static const IconData group_dm = IconData(0xf10c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_sign". - static const IconData hash_sign = IconData(0xf10b, fontFamily: "Zulip Icons"); + static const IconData hash_sign = IconData(0xf10d, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "image". + static const IconData image = IconData(0xf10e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "language". - static const IconData language = IconData(0xf10c, fontFamily: "Zulip Icons"); + static const IconData language = IconData(0xf10f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "lock". - static const IconData lock = IconData(0xf10d, fontFamily: "Zulip Icons"); + static const IconData lock = IconData(0xf110, fontFamily: "Zulip Icons"); /// The Zulip custom icon "mute". - static const IconData mute = IconData(0xf10e, fontFamily: "Zulip Icons"); + static const IconData mute = IconData(0xf111, fontFamily: "Zulip Icons"); /// The Zulip custom icon "read_receipts". - static const IconData read_receipts = IconData(0xf10f, fontFamily: "Zulip Icons"); + static const IconData read_receipts = IconData(0xf112, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "send". + static const IconData send = IconData(0xf113, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share". - static const IconData share = IconData(0xf110, fontFamily: "Zulip Icons"); + static const IconData share = IconData(0xf114, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share_ios". - static const IconData share_ios = IconData(0xf111, fontFamily: "Zulip Icons"); + static const IconData share_ios = IconData(0xf115, fontFamily: "Zulip Icons"); /// The Zulip custom icon "smile". - static const IconData smile = IconData(0xf112, fontFamily: "Zulip Icons"); + static const IconData smile = IconData(0xf116, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star". - static const IconData star = IconData(0xf113, fontFamily: "Zulip Icons"); + static const IconData star = IconData(0xf117, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star_filled". - static const IconData star_filled = IconData(0xf114, fontFamily: "Zulip Icons"); + static const IconData star_filled = IconData(0xf118, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf115, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf119, fontFamily: "Zulip Icons"); /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf116, fontFamily: "Zulip Icons"); + static const IconData unmute = IconData(0xf11a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "user". - static const IconData user = IconData(0xf117, fontFamily: "Zulip Icons"); + static const IconData user = IconData(0xf11b, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index 0fe1ac6cfd..f8335745d2 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -117,15 +117,19 @@ class DesignVariables extends ThemeExtension { bgContextMenu: const Color(0xfff2f2f2), bgCounterUnread: const Color(0xff666699).withValues(alpha: 0.15), bgTopBar: const Color(0xfff5f5f5), - borderBar: const Color(0x33000000), + borderBar: Colors.black.withValues(alpha: 0.2), + composeBoxBg: const Color(0xffffffff), contextMenuCancelText: const Color(0xff222222), contextMenuItemBg: const Color(0xff6159e1), contextMenuItemText: const Color(0xff381da7), - icon: const Color(0xff666699), + editorButtonPressedBg: Colors.black.withValues(alpha: 0.06), + foreground: const Color(0xff000000), + icon: const Color(0xff6159e1), labelCounterUnread: const Color(0xff222222), labelEdited: const HSLColor.fromAHSL(0.35, 0, 0, 0).toColor(), labelMenuButton: const Color(0xff222222), mainBackground: const Color(0xfff0f0f0), + textInput: const Color(0xff000000), title: const Color(0xff1a1a1a), channelColorSwatches: ChannelColorSwatches.light, atMentionMarker: const HSLColor.fromAHSL(0.5, 0, 0, 0.2).toColor(), @@ -153,15 +157,19 @@ class DesignVariables extends ThemeExtension { bgContextMenu: const Color(0xff262626), bgCounterUnread: const Color(0xff666699).withValues(alpha: 0.37), bgTopBar: const Color(0xff242424), - borderBar: Colors.black.withValues(alpha: 0.41), + borderBar: Colors.black.withValues(alpha: 0.5), + composeBoxBg: const Color(0xff0f0f0f), contextMenuCancelText: const Color(0xffffffff).withValues(alpha: 0.75), contextMenuItemBg: const Color(0xff7977fe), contextMenuItemText: const Color(0xff9398fd), - icon: const Color(0xff7070c2), + editorButtonPressedBg: Colors.white.withValues(alpha: 0.06), + foreground: const Color(0xffffffff), + icon: const Color(0xff7977fe), labelCounterUnread: const Color(0xffffffff).withValues(alpha: 0.7), labelEdited: const HSLColor.fromAHSL(0.35, 0, 0, 1).toColor(), labelMenuButton: const Color(0xffffffff).withValues(alpha: 0.85), mainBackground: const Color(0xff1d1d1d), + textInput: const Color(0xffffffff).withValues(alpha: 0.9), title: const Color(0xffffffff), channelColorSwatches: ChannelColorSwatches.dark, contextMenuCancelBg: const Color(0xff797986), // the same as the light mode in Figma @@ -197,14 +205,18 @@ class DesignVariables extends ThemeExtension { required this.bgCounterUnread, required this.bgTopBar, required this.borderBar, + required this.composeBoxBg, required this.contextMenuCancelText, required this.contextMenuItemBg, required this.contextMenuItemText, + required this.editorButtonPressedBg, + required this.foreground, required this.icon, required this.labelCounterUnread, required this.labelEdited, required this.labelMenuButton, required this.mainBackground, + required this.textInput, required this.title, required this.channelColorSwatches, required this.atMentionMarker, @@ -241,14 +253,18 @@ class DesignVariables extends ThemeExtension { final Color bgCounterUnread; final Color bgTopBar; final Color borderBar; + final Color composeBoxBg; final Color contextMenuCancelText; final Color contextMenuItemBg; final Color contextMenuItemText; + final Color editorButtonPressedBg; + final Color foreground; final Color icon; final Color labelCounterUnread; final Color labelEdited; final Color labelMenuButton; final Color mainBackground; + final Color textInput; final Color title; // Not exactly from the Figma design, but from Vlad anyway. @@ -280,14 +296,18 @@ class DesignVariables extends ThemeExtension { Color? bgCounterUnread, Color? bgTopBar, Color? borderBar, + Color? composeBoxBg, Color? contextMenuCancelText, Color? contextMenuItemBg, Color? contextMenuItemText, + Color? editorButtonPressedBg, + Color? foreground, Color? icon, Color? labelCounterUnread, Color? labelEdited, Color? labelMenuButton, Color? mainBackground, + Color? textInput, Color? title, ChannelColorSwatches? channelColorSwatches, Color? atMentionMarker, @@ -314,14 +334,18 @@ class DesignVariables extends ThemeExtension { bgCounterUnread: bgCounterUnread ?? this.bgCounterUnread, bgTopBar: bgTopBar ?? this.bgTopBar, borderBar: borderBar ?? this.borderBar, + composeBoxBg: composeBoxBg ?? this.composeBoxBg, contextMenuCancelText: contextMenuCancelText ?? this.contextMenuCancelText, contextMenuItemBg: contextMenuItemBg ?? this.contextMenuItemBg, contextMenuItemText: contextMenuItemText ?? this.contextMenuItemBg, + editorButtonPressedBg: editorButtonPressedBg ?? this.editorButtonPressedBg, + foreground: foreground ?? this.foreground, icon: icon ?? this.icon, labelCounterUnread: labelCounterUnread ?? this.labelCounterUnread, labelEdited: labelEdited ?? this.labelEdited, labelMenuButton: labelMenuButton ?? this.labelMenuButton, mainBackground: mainBackground ?? this.mainBackground, + textInput: textInput ?? this.textInput, title: title ?? this.title, channelColorSwatches: channelColorSwatches ?? this.channelColorSwatches, atMentionMarker: atMentionMarker ?? this.atMentionMarker, @@ -355,14 +379,18 @@ class DesignVariables extends ThemeExtension { bgCounterUnread: Color.lerp(bgCounterUnread, other.bgCounterUnread, t)!, bgTopBar: Color.lerp(bgTopBar, other.bgTopBar, t)!, borderBar: Color.lerp(borderBar, other.borderBar, t)!, + composeBoxBg: Color.lerp(composeBoxBg, other.composeBoxBg, t)!, contextMenuCancelText: Color.lerp(contextMenuCancelText, other.contextMenuCancelText, t)!, contextMenuItemBg: Color.lerp(contextMenuItemBg, other.contextMenuItemBg, t)!, contextMenuItemText: Color.lerp(contextMenuItemText, other.contextMenuItemBg, t)!, + editorButtonPressedBg: Color.lerp(editorButtonPressedBg, other.editorButtonPressedBg, t)!, + foreground: Color.lerp(foreground, other.foreground, t)!, icon: Color.lerp(icon, other.icon, t)!, labelCounterUnread: Color.lerp(labelCounterUnread, other.labelCounterUnread, t)!, labelEdited: Color.lerp(labelEdited, other.labelEdited, t)!, labelMenuButton: Color.lerp(labelMenuButton, other.labelMenuButton, t)!, mainBackground: Color.lerp(mainBackground, other.mainBackground, t)!, + textInput: Color.lerp(textInput, other.textInput, t)!, title: Color.lerp(title, other.title, t)!, channelColorSwatches: ChannelColorSwatches.lerp(channelColorSwatches, other.channelColorSwatches, t), atMentionMarker: Color.lerp(atMentionMarker, other.atMentionMarker, t)!, diff --git a/test/widgets/color_test.dart b/test/widgets/color_test.dart index 22f24a78b5..f88e9d3645 100644 --- a/test/widgets/color_test.dart +++ b/test/widgets/color_test.dart @@ -1,6 +1,6 @@ -import 'dart:ui'; - import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_checks/flutter_checks.dart'; import 'package:test/scaffolding.dart'; import 'package:zulip/widgets/color.dart'; @@ -14,5 +14,27 @@ void main() { check(Color(testCase).argbInt).equals(testCase); } }); + + const color = Color.fromRGBO(100, 200, 100, 0.5); + + test('withFadedAlpha smoke', () { + check(color.withFadedAlpha(0.5)) + .isSameColorAs(color.withValues(alpha: 0.25)); + }); + + test('withFadedAlpha opaque color', () { + const color = Colors.black; + + check(color.withFadedAlpha(0.5)) + .isSameColorAs(color.withValues(alpha: 0.5)); + }); + + test('withFadedAlpha factor > 1 fails', () { + check(() => color.withFadedAlpha(1.1)).throws(); + }); + + test('withFadedAlpha factor < 0 fails', () { + check(() => color.withFadedAlpha(-0.1)).throws(); + }); }); } diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 008b0f39c8..a19356ef40 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -17,8 +17,11 @@ import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/model/typing_status.dart'; import 'package:zulip/widgets/app.dart'; +import 'package:zulip/widgets/color.dart'; import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/page.dart'; +import 'package:zulip/widgets/icons.dart'; +import 'package:zulip/widgets/theme.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; @@ -299,7 +302,7 @@ void main() { connection.prepare(json: {}); connection.prepare(json: SendMessageResult(id: 123).toJson()); - await tester.tap(find.byIcon(Icons.send)); + await tester.tap(find.byIcon(ZulipIcons.send)); await tester.pump(Duration.zero); final requests = connection.takeRequests(); checkSetTypingStatusRequests([requests.first], [(TypingOp.stop, narrow)]); @@ -453,14 +456,15 @@ void main() { group('uploads', () { void checkAppearsLoading(WidgetTester tester, bool expected) { final sendButtonElement = tester.element(find.ancestor( - of: find.byIcon(Icons.send), + of: find.byIcon(ZulipIcons.send), matching: find.byType(IconButton))); final sendButtonWidget = sendButtonElement.widget as IconButton; - final colorScheme = Theme.of(sendButtonElement).colorScheme; - final expectedForegroundColor = expected - ? colorScheme.onSurface.withValues(alpha: 0.38) - : colorScheme.onPrimary; - check(sendButtonWidget.color).isNotNull().isSameColorAs(expectedForegroundColor); + final designVariables = DesignVariables.of(sendButtonElement); + final expectedIconColor = expected + ? designVariables.icon.withFadedAlpha(0.5) + : designVariables.icon; + check(sendButtonWidget.icon) + .isA().color.isNotNull().isSameColorAs(expectedIconColor); } group('attach from media library', () { @@ -492,7 +496,7 @@ void main() { connection.prepare(delay: const Duration(seconds: 1), json: UploadFileResult(uri: '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg').toJson()); - await tester.tap(find.byIcon(Icons.image)); + await tester.tap(find.byIcon(ZulipIcons.image)); await tester.pump(); final call = testBinding.takePickFilesCalls().single; check(call.allowMultiple).equals(true); @@ -554,7 +558,7 @@ void main() { connection.prepare(delay: const Duration(seconds: 1), json: UploadFileResult(uri: '/user_uploads/1/4e/m2A3MSqFnWRLUf9SaPzQ0Up_/image.jpg').toJson()); - await tester.tap(find.byIcon(Icons.camera_alt)); + await tester.tap(find.byIcon(ZulipIcons.camera)); await tester.pump(); final call = testBinding.takePickImageCalls().single; check(call.source).equals(ImageSource.camera); @@ -602,9 +606,9 @@ void main() { void checkComposeBoxParts({required bool areShown}) { final inputFieldCount = inputFieldFinder().evaluate().length; areShown ? check(inputFieldCount).isGreaterThan(0) : check(inputFieldCount).equals(0); - check(attachButtonFinder(Icons.attach_file).evaluate().length).equals(areShown ? 1 : 0); - check(attachButtonFinder(Icons.image).evaluate().length).equals(areShown ? 1 : 0); - check(attachButtonFinder(Icons.camera_alt).evaluate().length).equals(areShown ? 1 : 0); + check(attachButtonFinder(ZulipIcons.attach_file).evaluate().length).equals(areShown ? 1 : 0); + check(attachButtonFinder(ZulipIcons.image).evaluate().length).equals(areShown ? 1 : 0); + check(attachButtonFinder(ZulipIcons.camera).evaluate().length).equals(areShown ? 1 : 0); } void checkBannerWithLabel(String label, {required bool isShown}) { @@ -795,4 +799,67 @@ void main() { }); }); }); + + group('ComposeBox content input scaling', () { + const verticalPadding = 8; + final stream = eg.stream(); + final narrow = TopicNarrow(stream.streamId, 'foo'); + + Future checkContentInputMaxHeight(WidgetTester tester, { + required double maxHeight, + required int maxVisibleLines, + }) async { + TypingNotifier.debugEnable = false; + addTearDown(TypingNotifier.debugReset); + + // Add one line at a time, until the content input reaches its max height. + int numLines; + double? height; + for (numLines = 2; numLines <= 1000; numLines++) { + final content = List.generate(numLines, (_) => 'foo').join('\n'); + await tester.enterText(contentInputFinder, content); + await tester.pump(); + final newHeight = tester.getRect(contentInputFinder).height; + if (newHeight == height) { + break; + } + height = newHeight; + } + check(height).isNotNull().isCloseTo(maxHeight, 0.5); + // The last line added did not stretch the content input, + // so only the lines before it are at least partially visible. + check(numLines - 1).equals(maxVisibleLines); + } + + testWidgets('normal text scale factor', (tester) async { + await prepareComposeBox(tester, narrow: narrow, streams: [stream]); + + await checkContentInputMaxHeight(tester, + maxHeight: verticalPadding + 170, maxVisibleLines: 8); + }); + + testWidgets('lower text scale factor', (tester) async { + tester.platformDispatcher.textScaleFactorTestValue = 0.8; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + await prepareComposeBox(tester, narrow: narrow, streams: [stream]); + await checkContentInputMaxHeight(tester, + maxHeight: verticalPadding + 170 * 0.8, maxVisibleLines: 8); + }); + + testWidgets('higher text scale factor', (tester) async { + tester.platformDispatcher.textScaleFactorTestValue = 1.5; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + await prepareComposeBox(tester, narrow: narrow, streams: [stream]); + await checkContentInputMaxHeight(tester, + maxHeight: verticalPadding + 170 * 1.5, maxVisibleLines: 8); + }); + + testWidgets('higher text scale factor exceeding threshold', (tester) async { + tester.platformDispatcher.textScaleFactorTestValue = 2; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + await prepareComposeBox(tester, narrow: narrow, streams: [stream]); + await checkContentInputMaxHeight(tester, + maxHeight: verticalPadding + 170 * 1.5, maxVisibleLines: 6); + }); + }); } diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 444f78c4c9..37ce921f43 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -653,7 +653,7 @@ void main() { ..controller.isNotNull().text.equals('Some text'); connection.prepare(json: SendMessageResult(id: 1).toJson()); - await tester.tap(find.byIcon(Icons.send)); + await tester.tap(find.byIcon(ZulipIcons.send)); await tester.pump(); check(connection.lastRequest).isA() ..method.equals('POST')