From 907cdc7a9c93e00c39163038577f8c03bd549bf8 Mon Sep 17 00:00:00 2001 From: phibvcfc Date: Thu, 2 Jul 2026 16:42:45 +0700 Subject: [PATCH 1/2] ui: redesign error inspector header and summary bar with minimal layout and pulsing status indicators --- .../pages/error_inspector_page.dart | 673 ++++++++++++++++-- 1 file changed, 622 insertions(+), 51 deletions(-) diff --git a/lib/features/error_inspector/presentation/pages/error_inspector_page.dart b/lib/features/error_inspector/presentation/pages/error_inspector_page.dart index 8816ce5..7077e81 100644 --- a/lib/features/error_inspector/presentation/pages/error_inspector_page.dart +++ b/lib/features/error_inspector/presentation/pages/error_inspector_page.dart @@ -7,7 +7,6 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../../components/text/text_component.dart'; import '../../../../core/constants/app_constants.dart'; -import '../../../../components/feedback/empty_state.dart'; import '../../../../components/inputs/search_field.dart'; import '../../../../components/lists/stable_list_view.dart'; import '../../../../core/theme/color_tokens.dart'; @@ -273,39 +272,94 @@ class _ErrorInspectorPageState extends ConsumerState { @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; - final theme = Theme.of(context); final errorCount = ref.watch(errorCountProvider); final fatalCount = ref.watch(fatalErrorCountProvider); final activeFilters = ref.watch(errorFilterProvider); return Column( children: [ - // Header bar + // ── Header bar ──────────────────────────────────────────────────── + // Anti-card-overuse: just a `border-b` divider, no background + // container. Title left-aligned (variance 8) with the count pill + // glowing red + pulsing when there are active errors (perpetual + // motion; GSAP `repeat: -1` analog). Container( - height: 48, - padding: const EdgeInsets.symmetric(horizontal: 16), + height: 56, + padding: const EdgeInsets.fromLTRB(20, 0, 12, 0), decoration: BoxDecoration( - color: isDark ? ColorTokens.darkBackground : Colors.white, border: Border( bottom: BorderSide( - color: isDark ? Colors.white10 : Colors.black12, + color: isDark ? Colors.white10 : Colors.black.withValues(alpha: 0.06), ), ), ), child: Row( children: [ - // Title section + // Title — display weight, tight tracking Icon(LucideIcons.alertTriangle, size: 16, color: ColorTokens.logError), - const SizedBox(width: 8), - Text('Errors', style: theme.textTheme.titleMedium), - const SizedBox(width: 8), + const SizedBox(width: 10), + Text( + S.of(context).errors, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + color: isDark + ? ColorTokens.lightBackground + : ColorTokens.darkNeutral, + ), + ), + const SizedBox(width: 10), + // Count pill with pulsing red dot when errors > 0 ValueListenableBuilder( valueListenable: _entryCount, - builder: (context, count, _) => _CountPill(count: count), + builder: (context, count, _) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (count > 0) + Padding( + padding: const EdgeInsets.only(right: 4), + child: _PulsingDot( + color: ColorTokens.logError, + size: 7, + ), + ), + AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: count > 0 + ? ColorTokens.logError.withValues(alpha: 0.12) + : (isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.04)), + borderRadius: BorderRadius.circular(10), + ), + child: _CountUp( + value: count, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + fontFamily: AppConstants.monoFontFamily, + color: count > 0 + ? ColorTokens.logError + : (isDark + ? Colors.grey[400] + : Colors.grey[600]), + ), + ), + ), + ], + ); + }, ), + const SizedBox(width: 16), - // Platform filter chips + // Platform filter chips — compact, color-tinted ...ErrorPlatform.values.map((platform) { final isActive = activeFilters.contains(platform); return Padding( @@ -402,34 +456,96 @@ class _ErrorInspectorPageState extends ConsumerState { ], ), ), - // Summary cards - Padding( - padding: const EdgeInsets.all(16), + + // ── Unified info bar (replaces 2 cards + 4 platform chips) ──── + // No card containers, just a `border-b` + `divide-x` for visual + // structure. Single accent color (red) for totals, monochrome for + // platform counts. Tinted background only when count > 0 to + // communicate "no errors" without shouting. + Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 20), + decoration: BoxDecoration( + color: errorCount > 0 + ? ColorTokens.logError.withValues(alpha: 0.04) + : Colors.transparent, + border: Border( + bottom: BorderSide( + color: isDark ? Colors.white.withValues(alpha: 0.06) : Colors.black.withValues(alpha: 0.04), + ), + ), + ), child: Row( children: [ - _SummaryCard( - label: S.of(context).totalErrors, - value: errorCount.toString(), - icon: LucideIcons.alertTriangle, - color: ColorTokens.logError, + // Total — left-aligned, big monospace count, display weight + Expanded( + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _buildBarLabel( + icon: LucideIcons.alertTriangle, + label: S.of(context).totalErrors.toUpperCase(), + color: errorCount > 0 + ? ColorTokens.logError + : (isDark ? Colors.grey.shade500 : Colors.grey.shade600), + ), + const SizedBox(width: 10), + _CountUp( + value: errorCount, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + fontFamily: AppConstants.monoFontFamily, + letterSpacing: -0.5, + color: errorCount > 0 + ? ColorTokens.logError + : (isDark ? Colors.grey[300] : Colors.grey[700]), + ), + ), + ], + ), ), - const SizedBox(width: 12), - _SummaryCard( - label: S.of(context).fatalCrash, - value: fatalCount.toString(), - icon: LucideIcons.skull, - color: Colors.red, + _buildDivider(isDark), + Expanded( + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _buildBarLabel( + icon: LucideIcons.skull, + label: S.of(context).fatalCrash.toUpperCase(), + color: fatalCount > 0 + ? Colors.red + : (isDark ? Colors.grey.shade500 : Colors.grey.shade600), + ), + const SizedBox(width: 10), + _CountUp( + value: fatalCount, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + fontFamily: AppConstants.monoFontFamily, + letterSpacing: -0.5, + color: fatalCount > 0 + ? Colors.red + : (isDark ? Colors.grey[300] : Colors.grey[700]), + ), + ), + ], + ), ), - const SizedBox(width: 12), - _buildPlatformCounts(), + _buildDivider(isDark), + // Per-platform mini-bars + ..._buildPlatformCountBars(isDark), ], ), ), - // Error list + detail panel + + // ── Main content: list + detail panel ──────────────────────────── Expanded( child: _entries.isEmpty - ? EmptyState( - icon: LucideIcons.checkCircle, + ? _EmptyStateWithPulse( title: S.of(context).noErrorsCaptured, subtitle: S.of(context).errorsAppearHere, ) @@ -522,6 +638,101 @@ class _ErrorInspectorPageState extends ConsumerState { ); } + // ── Tiny helpers (no card containers) ─────────────────────────────── + + Widget _buildBarLabel({ + required IconData icon, + required String label, + required Color color, + }) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 11, color: color), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + color: color, + ), + ), + ], + ); + } + + Widget _buildDivider(bool isDark) { + return Container( + width: 1, + height: 24, + margin: const EdgeInsets.symmetric(horizontal: 20), + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.06), + ); + } + + /// Replaces the old row-of-`_CountChip` widgets with a unified + /// horizontal strip — each platform gets the same compact cell as + /// total/fatal, just rendered with its own color dot. + List _buildPlatformCountBars(bool isDark) { + final counts = ref.watch(errorCountByPlatformProvider); + final widgets = []; + for (final platform in ErrorPlatform.values) { + widgets.add(_buildDivider(isDark)); + final count = counts[platform] ?? 0; + final color = _platformColor(platform); + widgets.add( + Expanded( + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: count > 0 + ? color + : color.withValues(alpha: 0.3), + ), + ), + const SizedBox(width: 8), + Text( + _platformLabel(platform).toUpperCase(), + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + color: count > 0 + ? (isDark ? Colors.grey[200] : Colors.grey[800]) + : (isDark ? Colors.grey[500] : Colors.grey[600]), + ), + ), + const SizedBox(width: 8), + _CountUp( + value: count, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + fontFamily: AppConstants.monoFontFamily, + letterSpacing: -0.3, + color: count > 0 + ? (isDark ? Colors.grey[200] : Colors.grey[800]) + : (isDark ? Colors.grey[500] : Colors.grey[600]), + ), + ), + ], + ), + ), + ); + } + return widgets; + } + Widget _buildPlatformCounts() { final counts = ref.watch(errorCountByPlatformProvider); return Row( @@ -572,6 +783,293 @@ class _CountPill extends StatelessWidget { } } +// --------------------------------------------------------------------------- +// PulsingDot — small status indicator with perpetual motion +// (GSAP equivalent: a recursive tween with yoyo reverse) +// +// Used to draw the eye to a "live" status without being loud. Two +// stacked layers (expanding ring + solid dot) using `Curves.easeOutCubic` +// for the smooth deceleration GSAP ships out of the box. +// --------------------------------------------------------------------------- + +class _PulsingDot extends StatefulWidget { + final Color color; + final double size; + final Duration period; + + const _PulsingDot({ + required this.color, + this.size = 8, + this.period = const Duration(milliseconds: 1800), + }); + + @override + State<_PulsingDot> createState() => _PulsingDotState(); +} + +class _PulsingDotState extends State<_PulsingDot> + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController(vsync: this, duration: widget.period)..repeat(); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: widget.size * 3, + height: widget.size * 3, + child: Stack( + alignment: Alignment.center, + children: [ + // Outer expanding ring (perpetual "breathing" pulse) + AnimatedBuilder( + animation: _ctrl, + builder: (context, _) { + return Transform.scale( + scale: 0.6 + 0.8 * _ctrl.value, + child: Opacity( + opacity: 0.6 * (1 - _ctrl.value), + child: Container( + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: widget.color.withValues(alpha: 0.6), + ), + ), + ), + ); + }, + ), + // Solid dot (anchor) + Container( + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: widget.color, + ), + ), + ], + ), + ); + } +} + +// --------------------------------------------------------------------------- +// CountUp — animates a number to its target value +// (GSAP equivalent: `gsap.to(target, {val: newVal, duration: 0.6, ease: "power3.out"})`) +// --------------------------------------------------------------------------- + +class _CountUp extends StatelessWidget { + final int value; + final TextStyle? style; + final Duration duration; + final String Function(int)? formatter; + + const _CountUp({ + required this.value, + this.style, + this.duration = const Duration(milliseconds: 700), + this.formatter, + }); + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + tween: Tween(begin: 0, end: value.toDouble()), + duration: duration, + curve: Curves.easeOutCubic, + builder: (context, v, _) { + final n = v.toInt(); + return Text( + formatter != null ? formatter!(n) : '$n', + style: style, + ); + }, + ); + } +} + +// --------------------------------------------------------------------------- +// EmptyStateWithPulse — rich empty state with breathing shield + rings +// Replaces the bland `EmptyState(icon: checkCircle, …)` with a layout +// that has actual motion: a stacked shield with two concentric +// breathing rings, dotted-grid backdrop hint, and a title that +// fades + slides in. +// --------------------------------------------------------------------------- + +class _EmptyStateWithPulse extends StatefulWidget { + final String title; + final String subtitle; + + const _EmptyStateWithPulse({ + required this.title, + required this.subtitle, + }); + + @override + State<_EmptyStateWithPulse> createState() => _EmptyStateWithPulseState(); +} + +class _EmptyStateWithPulseState extends State<_EmptyStateWithPulse> + with TickerProviderStateMixin { + late final AnimationController _breathCtrl; + late final AnimationController _entryCtrl; + late final Animation _entryOpacity; + late final Animation _entrySlide; + + @override + void initState() { + super.initState(); + // Slow breathing loop for the rings (perpetual motion, no user trigger) + _breathCtrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 3200), + )..repeat(reverse: true); + // One-shot entrance animation for the title + icon + _entryCtrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 600), + ); + _entryOpacity = CurvedAnimation( + parent: _entryCtrl, + curve: Curves.easeOutCubic, + ); + _entrySlide = Tween( + begin: const Offset(0, 0.05), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _entryCtrl, + curve: Curves.easeOutCubic, + )); + _entryCtrl.forward(); + } + + @override + void dispose() { + _breathCtrl.dispose(); + _entryCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Center( + child: FadeTransition( + opacity: _entryOpacity, + child: SlideTransition( + position: _entrySlide, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Animated shield with breathing rings (perpetual motion) + SizedBox( + width: 120, + height: 120, + child: AnimatedBuilder( + animation: _breathCtrl, + builder: (context, _) { + return Stack( + alignment: Alignment.center, + children: [ + // Outer ring + Transform.scale( + scale: 0.85 + 0.15 * _breathCtrl.value, + child: Container( + width: 110, + height: 110, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: ColorTokens.logError + .withValues(alpha: 0.12 + 0.08 * _breathCtrl.value), + width: 1.5, + ), + ), + ), + ), + // Inner ring + Transform.scale( + scale: 0.55 + 0.10 * (1 - _breathCtrl.value), + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: ColorTokens.logError + .withValues(alpha: 0.20 + 0.10 * (1 - _breathCtrl.value)), + width: 1.5, + ), + ), + ), + ), + // Shield icon + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: ColorTokens.logError.withValues(alpha: 0.10), + border: Border.all( + color: ColorTokens.logError.withValues(alpha: 0.30), + width: 1, + ), + ), + child: Icon( + LucideIcons.shieldCheck, + size: 28, + color: ColorTokens.logError, + ), + ), + ], + ); + }, + ), + ), + const SizedBox(height: 24), + Text( + widget.title, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + color: isDark + ? ColorTokens.lightBackground + : ColorTokens.darkNeutral, + ), + ), + const SizedBox(height: 6), + Text( + widget.subtitle, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + color: isDark ? Colors.grey[500] : Colors.grey[600], + height: 1.4, + ), + ), + ], + ), + ), + ), + ); + } +} + // --------------------------------------------------------------------------- // Platform filter chip // --------------------------------------------------------------------------- @@ -593,46 +1091,119 @@ class _PlatformFilterChip extends StatefulWidget { State<_PlatformFilterChip> createState() => _PlatformFilterChipState(); } -class _PlatformFilterChipState extends State<_PlatformFilterChip> { +class _PlatformFilterChipState extends State<_PlatformFilterChip> + with SingleTickerProviderStateMixin { bool _hovered = false; + bool _pressed = false; + + // Short pop on tap — gsap.to(scale: 1.1, duration: 0.15) → scale: 1 + late final AnimationController _tapCtrl; + late final Animation _tapScale; + + @override + void initState() { + super.initState(); + _tapCtrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 220), + value: 1.0, + ); + _tapScale = Tween(begin: 1.0, end: 1.0).animate(_tapCtrl); + } + + @override + void dispose() { + _tapCtrl.dispose(); + super.dispose(); + } + + void _onTap() { + // Quick spring-back pop on press + _tapCtrl.duration = const Duration(milliseconds: 110); + _tapCtrl.reverse(from: 0.92); + Future.delayed(const Duration(milliseconds: 110), () { + if (mounted) { + _tapCtrl.duration = const Duration(milliseconds: 220); + _tapCtrl.forward(from: 1.0); + } + }); + widget.onTap(); + } @override Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final c = widget.color; + + // Background: active → filled tint, hover → faint tint, default → transparent final bg = widget.isActive - ? widget.color.withValues(alpha: 0.15) + ? c.withValues(alpha: isDark ? 0.18 : 0.14) : _hovered - ? widget.color.withValues(alpha: 0.07) + ? c.withValues(alpha: isDark ? 0.08 : 0.05) : Colors.transparent; - final borderColor = widget.isActive - ? widget.color.withValues(alpha: 0.4) + // Text color: active → saturated, hover → 60%, default → 45% muted + final textColor = widget.isActive + ? c : _hovered - ? widget.color.withValues(alpha: 0.25) - : Colors.grey.withValues(alpha: 0.2); + ? c.withValues(alpha: 0.85) + : c.withValues(alpha: 0.55); return Tooltip( message: '${widget.isActive ? "Hide" : "Show"} ${widget.label} errors', child: GestureDetector( - onTap: widget.onTap, + onTap: _onTap, child: MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: AnimatedContainer( - duration: const Duration(milliseconds: 120), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), decoration: BoxDecoration( color: bg, - borderRadius: BorderRadius.circular(6), - border: Border.all(color: borderColor), + borderRadius: BorderRadius.circular(8), + border: widget.isActive + ? Border.all(color: c.withValues(alpha: 0.35), width: 1) + : Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.06), + width: 1, + ), ), - child: Text( - widget.label, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - color: widget.color, - letterSpacing: 0.3, + child: AnimatedScale( + scale: _pressed ? 0.94 : 1.0, + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Color dot — pulses when active (perpetual motion) + AnimatedContainer( + duration: const Duration(milliseconds: 220), + width: 6, + height: 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: widget.isActive + ? c + : c.withValues(alpha: _hovered ? 0.85 : 0.5), + ), + ), + const SizedBox(width: 7), + Text( + widget.label, + style: TextStyle( + fontSize: 11, + fontWeight: + widget.isActive ? FontWeight.w700 : FontWeight.w500, + color: textColor, + letterSpacing: 0.2, + ), + ), + ], ), ), ), From b7d0b27791d72fe81701ec6f0c988853106efd85 Mon Sep 17 00:00:00 2001 From: phibvcfc Date: Thu, 2 Jul 2026 16:54:23 +0700 Subject: [PATCH 2/2] refactor: optimize pulse animation performance and update platform filter tap interaction --- .../pages/error_inspector_page.dart | 62 ++++++++++--------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/lib/features/error_inspector/presentation/pages/error_inspector_page.dart b/lib/features/error_inspector/presentation/pages/error_inspector_page.dart index 7077e81..bfbddba 100644 --- a/lib/features/error_inspector/presentation/pages/error_inspector_page.dart +++ b/lib/features/error_inspector/presentation/pages/error_inspector_page.dart @@ -535,8 +535,7 @@ class _ErrorInspectorPageState extends ConsumerState { ], ), ), - _buildDivider(isDark), - // Per-platform mini-bars + // Per-platform mini-bars (method prepends its own dividers) ..._buildPlatformCountBars(isDark), ], ), @@ -831,20 +830,26 @@ class _PulsingDotState extends State<_PulsingDot> child: Stack( alignment: Alignment.center, children: [ - // Outer expanding ring (perpetual "breathing" pulse) + // Outer expanding ring (perpetual "breathing" pulse). + // Animate the color's alpha directly instead of wrapping in an + // `Opacity` widget — that avoids intermediate offscreen render + // passes on every animation tick (the inner Container is a + // simple solid-color circle, so the Opacity layer is pure + // overhead). AnimatedBuilder( animation: _ctrl, builder: (context, _) { return Transform.scale( scale: 0.6 + 0.8 * _ctrl.value, - child: Opacity( - opacity: 0.6 * (1 - _ctrl.value), - child: Container( - width: widget.size, - height: widget.size, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: widget.color.withValues(alpha: 0.6), + child: Container( + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + shape: BoxShape.circle, + // Color alpha rides the same progress: starts at 0.6, + // fades to 0 as the ring expands. + color: widget.color.withValues( + alpha: 0.6 * (1 - _ctrl.value), ), ), ), @@ -1094,9 +1099,9 @@ class _PlatformFilterChip extends StatefulWidget { class _PlatformFilterChipState extends State<_PlatformFilterChip> with SingleTickerProviderStateMixin { bool _hovered = false; - bool _pressed = false; - // Short pop on tap — gsap.to(scale: 1.1, duration: 0.15) → scale: 1 + // Short pop on tap — gsap.to(scale: 0.92, duration: 0.11) → scale: 1 + // (drives ScaleTransition below). late final AnimationController _tapCtrl; late final Animation _tapScale; @@ -1105,10 +1110,13 @@ class _PlatformFilterChipState extends State<_PlatformFilterChip> super.initState(); _tapCtrl = AnimationController( vsync: this, - duration: const Duration(milliseconds: 220), - value: 1.0, + duration: const Duration(milliseconds: 110), + ); + // We drive `_tapCtrl` from 0 (no compression) to 1 (fully compressed) + // and map to scale 1.0 → 0.92 with easeOut. + _tapScale = Tween(begin: 1.0, end: 0.92).animate( + CurvedAnimation(parent: _tapCtrl, curve: Curves.easeOut), ); - _tapScale = Tween(begin: 1.0, end: 1.0).animate(_tapCtrl); } @override @@ -1117,17 +1125,13 @@ class _PlatformFilterChipState extends State<_PlatformFilterChip> super.dispose(); } - void _onTap() { - // Quick spring-back pop on press - _tapCtrl.duration = const Duration(milliseconds: 110); - _tapCtrl.reverse(from: 0.92); - Future.delayed(const Duration(milliseconds: 110), () { - if (mounted) { - _tapCtrl.duration = const Duration(milliseconds: 220); - _tapCtrl.forward(from: 1.0); - } - }); + Future _onTap() async { + // 0 → 1: scale shrinks to 0.92; on completion, reverse back to 1. + _tapCtrl.forward(from: 0); widget.onTap(); + await Future.delayed(const Duration(milliseconds: 110)); + if (!mounted) return; + _tapCtrl.reverse(); } @override @@ -1173,10 +1177,8 @@ class _PlatformFilterChipState extends State<_PlatformFilterChip> width: 1, ), ), - child: AnimatedScale( - scale: _pressed ? 0.94 : 1.0, - duration: const Duration(milliseconds: 120), - curve: Curves.easeOut, + child: ScaleTransition( + scale: _tapScale, child: Row( mainAxisSize: MainAxisSize.min, children: [