From 2e6025d8dd96ee97820d82e62d3708465f528dea Mon Sep 17 00:00:00 2001 From: Roscoe Rubin-Rottenberg Date: Sun, 24 May 2026 18:05:06 -0400 Subject: [PATCH] refactor(design system): reduce material dependency in atoms --- .../atoms/buttons/app_leading_button.dart | 5 +- .../buttons/app_overlay_back_button.dart | 5 +- .../atoms/buttons/circle_icon_button.dart | 2 +- .../atoms/buttons/interactive_pressable.dart | 106 +++++++++---- .../components/atoms/tab_item.dart | 16 +- .../components/atoms/tags/feed_tag.dart | 13 +- .../components/interactive_atoms_test.dart | 140 ++++++++++++++++++ 7 files changed, 239 insertions(+), 48 deletions(-) create mode 100644 test/src/core/design_system/components/interactive_atoms_test.dart diff --git a/lib/src/core/design_system/components/atoms/buttons/app_leading_button.dart b/lib/src/core/design_system/components/atoms/buttons/app_leading_button.dart index 4e8bb0e3..c373377c 100644 --- a/lib/src/core/design_system/components/atoms/buttons/app_leading_button.dart +++ b/lib/src/core/design_system/components/atoms/buttons/app_leading_button.dart @@ -1,5 +1,6 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; +import 'package:spark/src/core/design_system/components/atoms/buttons/interactive_pressable.dart'; import 'package:spark/src/core/design_system/components/atoms/icons.dart'; /// Design System leading button for app bars and template surfaces. @@ -80,8 +81,10 @@ class _SparkLeadingIconButton extends StatelessWidget { height: 40, child: Tooltip( message: tooltip, - child: GestureDetector( + child: InteractivePressable( onTap: onPressed, + semanticLabel: tooltip, + borderRadius: BorderRadius.circular(20), child: Center( child: AppIcons.chevronleft(color: iconColor, size: 28), ), diff --git a/lib/src/core/design_system/components/atoms/buttons/app_overlay_back_button.dart b/lib/src/core/design_system/components/atoms/buttons/app_overlay_back_button.dart index 28b2d46b..9b8d9db6 100644 --- a/lib/src/core/design_system/components/atoms/buttons/app_overlay_back_button.dart +++ b/lib/src/core/design_system/components/atoms/buttons/app_overlay_back_button.dart @@ -1,5 +1,6 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; +import 'package:spark/src/core/design_system/components/atoms/buttons/interactive_pressable.dart'; import 'package:spark/src/core/design_system/components/atoms/icons.dart'; import 'package:spark/src/core/design_system/tokens/colors.dart'; @@ -38,8 +39,10 @@ class AppOverlayBackButton extends StatelessWidget { height: 40, child: Tooltip( message: 'Back', - child: GestureDetector( + child: InteractivePressable( onTap: action, + semanticLabel: 'Back', + borderRadius: BorderRadius.circular(20), child: Center( child: AppIcons.chevronleft(color: color, size: 28), ), diff --git a/lib/src/core/design_system/components/atoms/buttons/circle_icon_button.dart b/lib/src/core/design_system/components/atoms/buttons/circle_icon_button.dart index e45e4bba..c7648b97 100644 --- a/lib/src/core/design_system/components/atoms/buttons/circle_icon_button.dart +++ b/lib/src/core/design_system/components/atoms/buttons/circle_icon_button.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:spark/src/core/design_system/components/atoms/buttons/interactive_pressable.dart'; /// A circular icon button component following the design system patterns. diff --git a/lib/src/core/design_system/components/atoms/buttons/interactive_pressable.dart b/lib/src/core/design_system/components/atoms/buttons/interactive_pressable.dart index d486840b..c840ea4d 100644 --- a/lib/src/core/design_system/components/atoms/buttons/interactive_pressable.dart +++ b/lib/src/core/design_system/components/atoms/buttons/interactive_pressable.dart @@ -1,23 +1,30 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; class InteractivePressable extends StatefulWidget { final Widget child; final VoidCallback? onTap; + final VoidCallback? onLongPress; final FocusNode? focusNode; final double pressedScale; final Duration duration; final Color overlayColor; final BorderRadius? borderRadius; + final String? semanticLabel; + final MouseCursor? mouseCursor; const InteractivePressable({ required this.child, super.key, this.onTap, + this.onLongPress, this.focusNode, this.pressedScale = 0.95, this.duration = const Duration(milliseconds: 120), - this.overlayColor = Colors.black26, + this.overlayColor = const Color(0x42000000), this.borderRadius, + this.semanticLabel, + this.mouseCursor, }); @override @@ -27,40 +34,81 @@ class InteractivePressable extends StatefulWidget { class _InteractivePressableState extends State { bool _isPressed = false; - void _onTapDown(TapDownDetails _) => setState(() => _isPressed = true); - void _onTapUp(TapUpDetails _) => setState(() => _isPressed = false); - void _onTapCancel() => setState(() => _isPressed = false); + bool get _isEnabled => widget.onTap != null || widget.onLongPress != null; + + void _setPressed(bool value) { + if (_isPressed == value) return; + setState(() => _isPressed = value); + } + + void _handleTapDown(TapDownDetails _) => _setPressed(true); + void _handleTapUp(TapUpDetails _) => _setPressed(false); + void _handleTapCancel() => _setPressed(false); + + void _handleKeyboardActivate() { + widget.onTap?.call(); + } @override Widget build(BuildContext context) { - final isEnabled = widget.onTap != null; + final isTapEnabled = widget.onTap != null; - return GestureDetector( + return Semantics( + label: widget.semanticLabel, + button: true, + enabled: _isEnabled, onTap: widget.onTap, - onTapDown: isEnabled ? _onTapDown : null, - onTapUp: isEnabled ? _onTapUp : null, - onTapCancel: isEnabled ? _onTapCancel : null, - child: AnimatedScale( - scale: isEnabled && _isPressed ? widget.pressedScale : 1.0, - duration: widget.duration, - curve: Curves.easeOut, - child: Stack( - alignment: Alignment.center, - children: [ - widget.child, - Positioned.fill( - child: AnimatedContainer( - duration: widget.duration, - curve: Curves.easeOut, - decoration: BoxDecoration( - color: isEnabled && _isPressed - ? widget.overlayColor - : Colors.transparent, - borderRadius: widget.borderRadius, + onLongPress: widget.onLongPress, + child: FocusableActionDetector( + enabled: _isEnabled, + focusNode: widget.focusNode, + mouseCursor: + widget.mouseCursor ?? + (_isEnabled ? SystemMouseCursors.click : SystemMouseCursors.basic), + shortcuts: const { + SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(), + SingleActivator(LogicalKeyboardKey.space): ActivateIntent(), + }, + actions: >{ + ActivateIntent: CallbackAction( + onInvoke: (_) { + if (isTapEnabled) { + _handleKeyboardActivate(); + } + return null; + }, + ), + }, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onTap, + onLongPress: widget.onLongPress, + onTapDown: _isEnabled ? _handleTapDown : null, + onTapUp: _isEnabled ? _handleTapUp : null, + onTapCancel: _isEnabled ? _handleTapCancel : null, + child: AnimatedScale( + scale: _isEnabled && _isPressed ? widget.pressedScale : 1.0, + duration: widget.duration, + curve: Curves.easeOut, + child: Stack( + alignment: Alignment.center, + children: [ + widget.child, + Positioned.fill( + child: AnimatedContainer( + duration: widget.duration, + curve: Curves.easeOut, + decoration: BoxDecoration( + color: _isEnabled && _isPressed + ? widget.overlayColor + : const Color(0x00000000), + borderRadius: widget.borderRadius, + ), + ), ), - ), + ], ), - ], + ), ), ), ); diff --git a/lib/src/core/design_system/components/atoms/tab_item.dart b/lib/src/core/design_system/components/atoms/tab_item.dart index d24b6f27..68231987 100644 --- a/lib/src/core/design_system/components/atoms/tab_item.dart +++ b/lib/src/core/design_system/components/atoms/tab_item.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:spark/src/core/design_system/components/atoms/buttons/interactive_pressable.dart'; /// A generic tab item with a bottom indicator that can wrap any widgets. class AppTabItem extends StatelessWidget { @@ -26,23 +27,16 @@ class AppTabItem extends StatelessWidget { final theme = Theme.of(context); return Expanded( - child: IconButton( - style: IconButton.styleFrom( - padding: EdgeInsets.zero, - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - shape: const RoundedRectangleBorder(), - splashFactory: NoSplash.splashFactory, - ), - onPressed: onTap, - icon: Container( + child: InteractivePressable( + onTap: onTap, + child: Container( padding: padding, decoration: BoxDecoration( border: Border( bottom: BorderSide( color: isSelected ? (indicatorColor ?? theme.colorScheme.primary) - : Colors.transparent, + : const Color(0x00000000), width: indicatorThickness, ), ), diff --git a/lib/src/core/design_system/components/atoms/tags/feed_tag.dart b/lib/src/core/design_system/components/atoms/tags/feed_tag.dart index 6ecfce9d..c30bbca6 100644 --- a/lib/src/core/design_system/components/atoms/tags/feed_tag.dart +++ b/lib/src/core/design_system/components/atoms/tags/feed_tag.dart @@ -1,7 +1,8 @@ import 'dart:ui'; -import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:spark/src/core/design_system/components/atoms/buttons/interactive_pressable.dart'; +import 'package:spark/src/core/design_system/tokens/colors.dart'; import 'package:spark/src/core/design_system/tokens/typography.dart'; class FeedTag extends StatelessWidget { @@ -28,7 +29,9 @@ class FeedTag extends StatelessWidget { borderRadius: radius, child: Text( text, - style: AppTypography.textMediumThin.copyWith(color: Colors.white70), + style: AppTypography.textMediumThin.copyWith( + color: AppColors.greyWhite.withValues(alpha: 0.7), + ), ), ); } @@ -43,15 +46,15 @@ class FeedTag extends StatelessWidget { height: 30, padding: const EdgeInsets.symmetric(horizontal: 10), decoration: BoxDecoration( - color: Colors.white.withAlpha(70), + color: AppColors.greyWhite.withAlpha(70), borderRadius: radius, - border: Border.all(color: Colors.white.withAlpha(55)), + border: Border.all(color: AppColors.greyWhite.withAlpha(55)), ), child: Center( child: Text( text, style: AppTypography.textMediumMedium.copyWith( - color: Colors.white, + color: AppColors.greyWhite, ), ), ), diff --git a/test/src/core/design_system/components/interactive_atoms_test.dart b/test/src/core/design_system/components/interactive_atoms_test.dart new file mode 100644 index 00000000..61542d54 --- /dev/null +++ b/test/src/core/design_system/components/interactive_atoms_test.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spark/src/core/design_system/components/atoms/buttons/interactive_pressable.dart'; +import 'package:spark/src/core/design_system/components/atoms/tab_item.dart'; +import 'package:spark/src/core/design_system/components/atoms/toggles/toggle_button.dart'; + +void main() { + testWidgets('InteractivePressable calls onTap for pointer activation', ( + tester, + ) async { + var tapCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: InteractivePressable( + onTap: () => tapCount++, + child: const SizedBox(width: 80, height: 40), + ), + ), + ), + ); + + await tester.tap(find.byType(InteractivePressable)); + + expect(tapCount, 1); + }); + + testWidgets('InteractivePressable disabled state blocks taps and semantics', ( + tester, + ) async { + final semanticsHandle = tester.ensureSemantics(); + var tapCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: InteractivePressable( + semanticLabel: 'Disabled action', + child: const SizedBox(width: 80, height: 40), + ), + ), + ), + ); + + await tester.tap(find.byType(InteractivePressable)); + + final node = tester.getSemantics(find.byType(InteractivePressable)); + expect(tapCount, 0); + expect(node.hasFlag(SemanticsFlag.isButton), isTrue); + expect(node.hasFlag(SemanticsFlag.hasEnabledState), isTrue); + expect(node.hasFlag(SemanticsFlag.isEnabled), isFalse); + + semanticsHandle.dispose(); + }); + + testWidgets('InteractivePressable activates with keyboard focus', ( + tester, + ) async { + final focusNode = FocusNode(); + var tapCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: InteractivePressable( + focusNode: focusNode, + onTap: () => tapCount++, + child: const SizedBox(width: 80, height: 40), + ), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + + expect(tapCount, 1); + + focusNode.dispose(); + }); + + testWidgets('AppTabItem fills available width and calls onTap', ( + tester, + ) async { + var tapCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox( + width: 300, + child: Row( + children: [ + AppTabItem( + activeChild: const Text('Selected'), + inactiveChild: const Text('Unselected'), + isSelected: true, + onTap: () => tapCount++, + ), + ], + ), + ), + ), + ), + ); + + expect(tester.getSize(find.byType(InteractivePressable)).width, 300); + + await tester.tap(find.byType(InteractivePressable)); + + expect(tapCount, 1); + }); + + testWidgets('ToggleButton calls onChanged with inverse selected value', ( + tester, + ) async { + bool? changedValue; + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: ToggleButton( + isSelected: true, + selectedLabel: 'Unfollow', + unselectedLabel: 'Follow', + onChanged: (value) => changedValue = value, + ), + ), + ), + ); + + await tester.tap(find.byType(ToggleButton)); + + expect(changedValue, isFalse); + }); +} -- 2.51.2