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')