diff --git a/lib/widgets/button.dart b/lib/widgets/button.dart new file mode 100644 index 0000000000..fb5968b97a --- /dev/null +++ b/lib/widgets/button.dart @@ -0,0 +1,197 @@ +import 'package:flutter/material.dart'; + +import 'color.dart'; +import 'text.dart'; +import 'theme.dart'; + +/// The "Button" component from Zulip Web UI kit, +/// plus outer vertical padding to make the touch target 44px tall. +/// +/// The Figma uses this for the "Cancel" and "Save" buttons in the compose box +/// for editing an already-sent message. +/// +/// See Figma: +/// * Component: https://www.figma.com/design/msWyAJ8cnMHgOMPxi7BUvA/Zulip-Web-UI-kit?node-id=1-2780&t=Wia0D0i1I0GXdD9z-0 +/// * Edit-message compose box: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3988-38201&m=dev +class ZulipWebUiKitButton extends StatelessWidget { + const ZulipWebUiKitButton({ + super.key, + this.attention = ZulipWebUiKitButtonAttention.medium, + this.intent = ZulipWebUiKitButtonIntent.info, + required this.label, + required this.onPressed, + }); + + final ZulipWebUiKitButtonAttention attention; + final ZulipWebUiKitButtonIntent intent; + final String label; + final VoidCallback onPressed; + + WidgetStateColor _backgroundColor(DesignVariables designVariables) { + switch ((attention, intent)) { + case (ZulipWebUiKitButtonAttention.medium, ZulipWebUiKitButtonIntent.info): + return WidgetStateColor.fromMap({ + WidgetState.pressed: designVariables.btnBgAttMediumIntInfoActive, + ~WidgetState.pressed: designVariables.btnBgAttMediumIntInfoNormal, + }); + case (ZulipWebUiKitButtonAttention.high, ZulipWebUiKitButtonIntent.info): + return WidgetStateColor.fromMap({ + WidgetState.pressed: designVariables.btnBgAttHighIntInfoActive, + ~WidgetState.pressed: designVariables.btnBgAttHighIntInfoNormal, + }); + } + } + + Color _labelColor(DesignVariables designVariables) { + switch ((attention, intent)) { + case (ZulipWebUiKitButtonAttention.medium, ZulipWebUiKitButtonIntent.info): + return designVariables.btnLabelAttMediumIntInfo; + case (ZulipWebUiKitButtonAttention.high, ZulipWebUiKitButtonIntent.info): + return designVariables.btnLabelAttHigh; + } + } + + TextStyle _labelStyle(BuildContext context, {required TextScaler textScaler}) { + final designVariables = DesignVariables.of(context); + // Values chosen from the Figma frame for zulip-flutter's compose box: + // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3988-38201&m=dev + // Commented values come from the Figma page "Zulip Web UI kit": + // https://www.figma.com/design/msWyAJ8cnMHgOMPxi7BUvA/Zulip-Web-UI-kit?node-id=1-8&p=f&m=dev + // Discussion: + // https://github.com/zulip/zulip-flutter/pull/1432#discussion_r2023880851 + return TextStyle( + color: _labelColor(designVariables), + fontSize: 17, // 16 + height: 1.20, // 1.25 + letterSpacing: proportionalLetterSpacing(context, textScaler: textScaler, + 0.006, + baseFontSize: 17), // 16 + ).merge(weightVariableTextStyle(context, + wght: 600)); // 500 + } + + BorderSide _borderSide(DesignVariables designVariables) { + switch (attention) { + case ZulipWebUiKitButtonAttention.medium: + // TODO inner shadow effect like `box-shadow: inset`, following Figma; + // needs Flutter support for something like that: + // https://github.com/flutter/flutter/issues/18636 + // https://github.com/flutter/flutter/issues/52999 + // For now, we just use a solid-stroke border with half the opacity + // and half the width. + return BorderSide( + color: designVariables.btnShadowAttMed.withFadedAlpha(0.5), + width: 0.5); + case ZulipWebUiKitButtonAttention.high: + return BorderSide.none; + } + } + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + + // With [MaterialTapTargetSize.padded], + // make [TextButton] set 44 instead of 48 for the touch-target height. + final visualDensity = VisualDensity(vertical: -1); + // A value that [TextButton] adds to some of its layout parameters; + // we can cancel out those adjustments by subtracting it. + final densityVerticalAdjustment = visualDensity.baseSizeAdjustment.dy; + + // An upper limit when the text-size setting is large + // - helps prioritize more important content (like message content); #1023 + // - prevents the vertical padding added by [MaterialTapTargetSize.padded] + // from shrinking to zero as the button grows to accommodate a larger label + final textScaler = MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 1.5); + + return AnimatedScaleOnTap( + scaleEnd: 0.96, + duration: Duration(milliseconds: 100), + child: TextButton( + style: TextButton.styleFrom( + padding: EdgeInsets.symmetric(horizontal: 10, vertical: 4 - densityVerticalAdjustment), + foregroundColor: _labelColor(designVariables), + shape: RoundedRectangleBorder( + side: _borderSide(designVariables), + borderRadius: BorderRadius.circular(4)), + splashFactory: NoSplash.splashFactory, + + // These three arguments make the button 28px tall vertically, + // but with vertical padding to make the touch target 44px tall: + // https://github.com/zulip/zulip-flutter/pull/1432#discussion_r2023907300 + visualDensity: visualDensity, + tapTargetSize: MaterialTapTargetSize.padded, + minimumSize: Size(kMinInteractiveDimension, 28 - densityVerticalAdjustment), + ).copyWith(backgroundColor: _backgroundColor(designVariables)), + onPressed: onPressed, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 240), + child: Text(label, + textScaler: textScaler, + maxLines: 1, + style: _labelStyle(context, textScaler: textScaler), + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis)))); + } +} + +enum ZulipWebUiKitButtonAttention { + high, + medium, + // low, +} + +enum ZulipWebUiKitButtonIntent { + // neutral, + // warning, + // danger, + info, + // success, + // brand, +} + +/// Apply [Transform.scale] to the child widget when tapped, and reset its scale +/// when released, while animating the transitions. +class AnimatedScaleOnTap extends StatefulWidget { + const AnimatedScaleOnTap({ + super.key, + required this.scaleEnd, + required this.duration, + required this.child, + }); + + /// The terminal scale to animate to. + final double scaleEnd; + + /// The duration over which to animate the scale change. + final Duration duration; + + final Widget child; + + @override + State createState() => _AnimatedScaleOnTapState(); +} + +class _AnimatedScaleOnTapState extends State { + double _scale = 1; + + void _changeScale(double scale) { + setState(() { + _scale = scale; + }); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTapDown: (_) => _changeScale(widget.scaleEnd), + onTapUp: (_) => _changeScale(1), + onTapCancel: () => _changeScale(1), + child: AnimatedScale( + scale: _scale, + duration: widget.duration, + curve: Curves.easeOut, + child: widget.child)); + } +} diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart index f444f0a6a5..ab5ad446db 100644 --- a/lib/widgets/home.dart +++ b/lib/widgets/home.dart @@ -8,6 +8,7 @@ import 'about_zulip.dart'; import 'action_sheet.dart'; import 'app.dart'; import 'app_bar.dart'; +import 'button.dart'; import 'color.dart'; import 'content.dart'; import 'icons.dart'; @@ -601,49 +602,3 @@ class _AboutZulipButton extends _MenuButton { Navigator.of(context).push(AboutZulipPage.buildRoute(context)); } } - -/// Apply [Transform.scale] to the child widget when tapped, and reset its scale -/// when released, while animating the transitions. -class AnimatedScaleOnTap extends StatefulWidget { - const AnimatedScaleOnTap({ - super.key, - required this.scaleEnd, - required this.duration, - required this.child, - }); - - /// The terminal scale to animate to. - final double scaleEnd; - - /// The duration over which to animate the scale change. - final Duration duration; - - final Widget child; - - @override - State createState() => _AnimatedScaleOnTapState(); -} - -class _AnimatedScaleOnTapState extends State { - double _scale = 1; - - void _changeScale(double scale) { - setState(() { - _scale = scale; - }); - } - - @override - Widget build(BuildContext context) { - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTapDown: (_) => _changeScale(widget.scaleEnd), - onTapUp: (_) => _changeScale(1), - onTapCancel: () => _changeScale(1), - child: AnimatedScale( - scale: _scale, - duration: widget.duration, - curve: Curves.easeOut, - child: widget.child)); - } -} diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index abee956825..eea9677045 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -138,8 +138,15 @@ class DesignVariables extends ThemeExtension { bgTopBar: const Color(0xfff5f5f5), borderBar: Colors.black.withValues(alpha: 0.2), borderMenuButtonSelected: Colors.black.withValues(alpha: 0.2), + btnBgAttHighIntInfoActive: const Color(0xff1e41d3), + btnBgAttHighIntInfoNormal: const Color(0xff3c6bff), + btnBgAttMediumIntInfoActive: const Color(0xff3c6bff).withValues(alpha: 0.22), + btnBgAttMediumIntInfoNormal: const Color(0xff3c6bff).withValues(alpha: 0.12), + btnLabelAttHigh: const Color(0xffffffff), btnLabelAttLowIntDanger: const Color(0xffc0070a), btnLabelAttMediumIntDanger: const Color(0xffac0508), + btnLabelAttMediumIntInfo: const Color(0xff1027a6), + btnShadowAttMed: const Color(0xff000000).withValues(alpha: 0.20), composeBoxBg: const Color(0xffffffff), contextMenuCancelText: const Color(0xff222222), contextMenuItemBg: const Color(0xff6159e1), @@ -188,8 +195,15 @@ class DesignVariables extends ThemeExtension { bgTopBar: const Color(0xff242424), borderBar: const Color(0xffffffff).withValues(alpha: 0.1), borderMenuButtonSelected: Colors.white.withValues(alpha: 0.1), + btnBgAttHighIntInfoActive: const Color(0xff1e41d3), + btnBgAttHighIntInfoNormal: const Color(0xff1e41d3), + btnBgAttMediumIntInfoActive: const Color(0xff97b6fe).withValues(alpha: 0.12), + btnBgAttMediumIntInfoNormal: const Color(0xff97b6fe).withValues(alpha: 0.12), + btnLabelAttHigh: const Color(0xffffffff).withValues(alpha: 0.85), btnLabelAttLowIntDanger: const Color(0xffff8b7c), btnLabelAttMediumIntDanger: const Color(0xffff8b7c), + btnLabelAttMediumIntInfo: const Color(0xff97b6fe), + btnShadowAttMed: const Color(0xffffffff).withValues(alpha: 0.21), composeBoxBg: const Color(0xff0f0f0f), contextMenuCancelText: const Color(0xffffffff).withValues(alpha: 0.75), contextMenuItemBg: const Color(0xff7977fe), @@ -246,8 +260,15 @@ class DesignVariables extends ThemeExtension { required this.bgTopBar, required this.borderBar, required this.borderMenuButtonSelected, + required this.btnBgAttHighIntInfoActive, + required this.btnBgAttHighIntInfoNormal, + required this.btnBgAttMediumIntInfoActive, + required this.btnBgAttMediumIntInfoNormal, + required this.btnLabelAttHigh, required this.btnLabelAttLowIntDanger, required this.btnLabelAttMediumIntDanger, + required this.btnLabelAttMediumIntInfo, + required this.btnShadowAttMed, required this.composeBoxBg, required this.contextMenuCancelText, required this.contextMenuItemBg, @@ -305,8 +326,15 @@ class DesignVariables extends ThemeExtension { final Color bgTopBar; final Color borderBar; final Color borderMenuButtonSelected; + final Color btnBgAttHighIntInfoActive; + final Color btnBgAttHighIntInfoNormal; + final Color btnBgAttMediumIntInfoActive; + final Color btnBgAttMediumIntInfoNormal; + final Color btnLabelAttHigh; final Color btnLabelAttLowIntDanger; final Color btnLabelAttMediumIntDanger; + final Color btnLabelAttMediumIntInfo; + final Color btnShadowAttMed; final Color composeBoxBg; final Color contextMenuCancelText; final Color contextMenuItemBg; @@ -359,8 +387,15 @@ class DesignVariables extends ThemeExtension { Color? bgTopBar, Color? borderBar, Color? borderMenuButtonSelected, + Color? btnBgAttHighIntInfoActive, + Color? btnBgAttHighIntInfoNormal, + Color? btnBgAttMediumIntInfoActive, + Color? btnBgAttMediumIntInfoNormal, + Color? btnLabelAttHigh, Color? btnLabelAttLowIntDanger, Color? btnLabelAttMediumIntDanger, + Color? btnLabelAttMediumIntInfo, + Color? btnShadowAttMed, Color? composeBoxBg, Color? contextMenuCancelText, Color? contextMenuItemBg, @@ -408,8 +443,15 @@ class DesignVariables extends ThemeExtension { bgTopBar: bgTopBar ?? this.bgTopBar, borderBar: borderBar ?? this.borderBar, borderMenuButtonSelected: borderMenuButtonSelected ?? this.borderMenuButtonSelected, + btnBgAttHighIntInfoActive: btnBgAttHighIntInfoActive ?? this.btnBgAttHighIntInfoActive, + btnBgAttHighIntInfoNormal: btnBgAttHighIntInfoNormal ?? this.btnBgAttHighIntInfoNormal, + btnBgAttMediumIntInfoActive: btnBgAttMediumIntInfoActive ?? this.btnBgAttMediumIntInfoActive, + btnBgAttMediumIntInfoNormal: btnBgAttMediumIntInfoNormal ?? this.btnBgAttMediumIntInfoNormal, + btnLabelAttHigh: btnLabelAttHigh ?? this.btnLabelAttHigh, btnLabelAttLowIntDanger: btnLabelAttLowIntDanger ?? this.btnLabelAttLowIntDanger, btnLabelAttMediumIntDanger: btnLabelAttMediumIntDanger ?? this.btnLabelAttMediumIntDanger, + btnLabelAttMediumIntInfo: btnLabelAttMediumIntInfo ?? this.btnLabelAttMediumIntInfo, + btnShadowAttMed: btnShadowAttMed ?? this.btnShadowAttMed, composeBoxBg: composeBoxBg ?? this.composeBoxBg, contextMenuCancelText: contextMenuCancelText ?? this.contextMenuCancelText, contextMenuItemBg: contextMenuItemBg ?? this.contextMenuItemBg, @@ -464,8 +506,15 @@ class DesignVariables extends ThemeExtension { bgTopBar: Color.lerp(bgTopBar, other.bgTopBar, t)!, borderBar: Color.lerp(borderBar, other.borderBar, t)!, borderMenuButtonSelected: Color.lerp(borderMenuButtonSelected, other.borderMenuButtonSelected, t)!, + btnBgAttHighIntInfoActive: Color.lerp(btnBgAttHighIntInfoActive, other.btnBgAttHighIntInfoActive, t)!, + btnBgAttHighIntInfoNormal: Color.lerp(btnBgAttHighIntInfoNormal, other.btnBgAttHighIntInfoNormal, t)!, + btnBgAttMediumIntInfoActive: Color.lerp(btnBgAttMediumIntInfoActive, other.btnBgAttMediumIntInfoActive, t)!, + btnBgAttMediumIntInfoNormal: Color.lerp(btnBgAttMediumIntInfoNormal, other.btnBgAttMediumIntInfoNormal, t)!, + btnLabelAttHigh: Color.lerp(btnLabelAttHigh, other.btnLabelAttHigh, t)!, btnLabelAttLowIntDanger: Color.lerp(btnLabelAttLowIntDanger, other.btnLabelAttLowIntDanger, t)!, btnLabelAttMediumIntDanger: Color.lerp(btnLabelAttMediumIntDanger, other.btnLabelAttMediumIntDanger, t)!, + btnLabelAttMediumIntInfo: Color.lerp(btnLabelAttMediumIntInfo, other.btnLabelAttMediumIntInfo, t)!, + btnShadowAttMed: Color.lerp(btnShadowAttMed, other.btnShadowAttMed, t)!, composeBoxBg: Color.lerp(composeBoxBg, other.composeBoxBg, t)!, contextMenuCancelText: Color.lerp(contextMenuCancelText, other.contextMenuCancelText, t)!, contextMenuItemBg: Color.lerp(contextMenuItemBg, other.contextMenuItemBg, t)!, diff --git a/test/widgets/button_test.dart b/test/widgets/button_test.dart new file mode 100644 index 0000000000..da9136b8a2 --- /dev/null +++ b/test/widgets/button_test.dart @@ -0,0 +1,87 @@ +import 'dart:math'; + +import 'package:checks/checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; +import 'package:legacy_checks/legacy_checks.dart'; +import 'package:zulip/widgets/button.dart'; + +import '../flutter_checks.dart'; +import '../model/binding.dart'; +import 'test_app.dart'; +import 'text_test.dart'; + + +void main() { + TestZulipBinding.ensureInitialized(); + + group('ZulipWebUiKitButton', () { + final textScaleFactorVariants = ValueVariant(Set.of(kTextScaleFactors)); + + testWidgets('vertical outer padding is preserved as text scales', (tester) async { + addTearDown(testBinding.reset); + tester.platformDispatcher.textScaleFactorTestValue = textScaleFactorVariants.currentValue!; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + + final buttonFinder = find.byType(ZulipWebUiKitButton); + + await tester.pumpWidget(TestZulipApp( + child: UnconstrainedBox( + child: ZulipWebUiKitButton(label: 'Cancel', onPressed: () {})))); + await tester.pump(); + + final element = tester.element(buttonFinder); + final renderObject = element.renderObject as RenderBox; + final size = renderObject.size; + check(size).height.equals(44); // includes outer padding + + final textScaler = TextScaler.linear(textScaleFactorVariants.currentValue!) + .clamp(maxScaleFactor: 1.5); + final expectedButtonHeight = max(28.0, // configured min height + (textScaler.scale(17) * 1.20).roundToDouble() // text height + + 4 + 4); // vertical padding + + // Rounded rectangle paints with the intended height… + final expectedRRect = RRect.fromLTRBR( + 0, 0, // zero relative to the position at this paint step + size.width, expectedButtonHeight, Radius.circular(4)); + check(renderObject).legacyMatcher( + // `paints` isn't a [Matcher] so we wrap it with `equals`; + // awkward but it works + equals(paints..drrect(outer: expectedRRect))); + + // …and that height leaves at least 4px for vertical outer padding. + check(expectedButtonHeight).isLessOrEqual(44 - 2 - 2); + }, variant: textScaleFactorVariants); + + testWidgets('vertical outer padding responds to taps, not just painted area', (tester) async { + addTearDown(testBinding.reset); + tester.platformDispatcher.textScaleFactorTestValue = textScaleFactorVariants.currentValue!; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + + final buttonFinder = find.byType(ZulipWebUiKitButton); + + int numTapsHandled = 0; + await tester.pumpWidget(TestZulipApp( + child: UnconstrainedBox( + child: ZulipWebUiKitButton( + label: 'Cancel', + onPressed: () => numTapsHandled++)))); + await tester.pump(); + + final element = tester.element(buttonFinder); + final renderObject = element.renderObject as RenderBox; + final size = renderObject.size; + check(size).height.equals(44); // includes outer padding + + // Outer padding responds to taps, not just the painted part. + final buttonCenter = tester.getCenter(buttonFinder); + int numTaps = 0; + for (double y = -22; y < 22; y++) { + await tester.tapAt(buttonCenter + Offset(0, y)); + numTaps++; + } + check(numTapsHandled).equals(numTaps); + }, variant: textScaleFactorVariants); + }); +}