feat(material): animate header action groups and collapse

This commit is contained in:
zarzet committed 2026-10-14 23:04:07 +07:00
1 parent 736cfcd081
commit 56e2eb8589
9 files changed
+477 -195

No files matched your search

+9 -12
View File
@@ -21,6 +21,7 @@ import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart';
import 'package:spotiflac_android/screens/selection_mode_mixin.dart';
import 'package:spotiflac_android/widgets/error_card.dart';
import 'package:spotiflac_android/widgets/album_detail_header.dart';
import 'package:spotiflac_android/widgets/expressive_button_group.dart';
import 'package:spotiflac_android/widgets/album_description.dart';
import 'package:spotiflac_android/utils/editorial_notes.dart';
import 'package:spotiflac_android/utils/adaptive_layout.dart';
@@ -632,21 +633,17 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
_buildAddToPlaylistButton(context),
],
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
: ExpressiveButtonGroup(
flexible: const {1},
children: [
_buildLoveAllButton(),
const SizedBox(width: 12),
Flexible(
child: HeaderFilledButton(
icon: Icons.download,
label: context.l10n.downloadAllCount(tracks.length),
onPressed: tracks.isEmpty
? null
: () => _downloadAll(context),
),
HeaderFilledButton(
icon: Icons.download,
label: context.l10n.downloadAllCount(tracks.length),
onPressed: tracks.isEmpty
? null
: () => _downloadAll(context),
),
const SizedBox(width: 12),
_buildAddToPlaylistButton(context),
],
),
+4
View File
@@ -53,6 +53,10 @@ import 'package:spotiflac_android/widgets/cross_extension_share_sheet.dart';
import 'package:spotiflac_android/widgets/view_queue_snackbar_action.dart';
import 'package:spotiflac_android/widgets/app_snack_bar.dart';
import 'package:spotiflac_android/widgets/downloadable_cover.dart';
import 'package:spotiflac_android/widgets/expressive_button_group.dart';
import 'package:spotiflac_android/widgets/expressive_icon_button.dart';
import 'package:spotiflac_android/theme/app_motion.dart';
import 'package:spotiflac_android/theme/material_expressive.dart';
part 'artist_screen_widgets.dart';
+113 -55
View File
@@ -168,26 +168,42 @@ extension _ArtistScreenSections on _ArtistScreenState {
required bool isFavoriteArtist,
}) {
final edgeInset = detailHeaderEdgeInset(context);
const fallbackMotion = AppMotionSpec(
Duration(milliseconds: 200),
Curves.linear,
);
final titleFade = AppMotion.effects(context, fallbackMotion);
Widget toolbarTitle = AnimatedOpacity(
duration: titleFade.duration,
curve: titleFade.curve,
opacity: _showTitleInAppBar ? 1.0 : 0.0,
child: Text(
widget.artistName,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
fontSize: 16,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
);
if (materialExpressiveEnabled(context)) {
final titleSlide = AppMotion.spatial(context, fallbackMotion);
toolbarTitle = AnimatedSlide(
duration: titleSlide.duration,
curve: titleSlide.curve,
offset: _showTitleInAppBar ? Offset.zero : const Offset(0, 0.6),
child: toolbarTitle,
);
}
return SliverAppBar(
expandedHeight: hasDiscography ? 420 : 380,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
title: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _showTitleInAppBar ? 1.0 : 0.0,
child: Text(
widget.artistName,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
fontSize: 16,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
title: toolbarTitle,
flexibleSpace: FlexibleSpaceBar(
collapseMode: CollapseMode.none,
background: Stack(
@@ -313,50 +329,92 @@ extension _ArtistScreenSections on _ArtistScreenState {
),
if (!isSelectionMode) ...[
const SizedBox(width: 12),
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: headerScheme.primaryContainer,
shape: BoxShape.circle,
),
child: AppIconButton(
onPressed: () => _toggleFavoriteArtist(context),
icon: Icon(
isFavoriteArtist
? Icons.favorite
: Icons.favorite_border,
size: 26,
if (materialExpressiveEnabled(context))
// Favourite morphs to a square while selected, and the
// pair squishes like a button group when pressed.
ExpressiveButtonGroup(
slotWidth: 52,
children: [
ExpressiveIconButton(
onPressed: () => _toggleFavoriteArtist(context),
icon: Icon(
isFavoriteArtist
? Icons.favorite
: Icons.favorite_border,
),
selected: isFavoriteArtist,
size: 52,
iconSize: 26,
backgroundColor: headerScheme.primaryContainer,
foregroundColor: isFavoriteArtist
? headerScheme.error
: headerScheme.onPrimaryContainer,
tooltip: isFavoriteArtist
? context.l10n.artistOptionRemoveFromFavorites
: context.l10n.artistOptionAddToFavorites,
),
if (hasDiscography)
ExpressiveIconButton(
onPressed: () => _showDiscographyOptions(
context,
colorScheme,
albums,
),
icon: const Icon(Icons.download_rounded),
size: 52,
iconSize: 26,
backgroundColor: headerScheme.primaryContainer,
foregroundColor: headerScheme.onPrimaryContainer,
tooltip: context.l10n.discographyDownload,
),
],
)
else ...[
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: headerScheme.primaryContainer,
shape: BoxShape.circle,
),
color: isFavoriteArtist
? headerScheme.error
: headerScheme.onPrimaryContainer,
tooltip: isFavoriteArtist
? context.l10n.artistOptionRemoveFromFavorites
: context.l10n.artistOptionAddToFavorites,
),
),
],
if (hasDiscography && !isSelectionMode) ...[
const SizedBox(width: 12),
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: headerScheme.primaryContainer,
shape: BoxShape.circle,
),
child: AppIconButton(
onPressed: () => _showDiscographyOptions(
context,
colorScheme,
albums,
child: AppIconButton(
onPressed: () => _toggleFavoriteArtist(context),
icon: Icon(
isFavoriteArtist
? Icons.favorite
: Icons.favorite_border,
size: 26,
),
color: isFavoriteArtist
? headerScheme.error
: headerScheme.onPrimaryContainer,
tooltip: isFavoriteArtist
? context.l10n.artistOptionRemoveFromFavorites
: context.l10n.artistOptionAddToFavorites,
),
icon: const Icon(Icons.download_rounded, size: 26),
color: headerScheme.onPrimaryContainer,
tooltip: context.l10n.discographyDownload,
),
),
if (hasDiscography) ...[
const SizedBox(width: 12),
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: headerScheme.primaryContainer,
shape: BoxShape.circle,
),
child: AppIconButton(
onPressed: () => _showDiscographyOptions(
context,
colorScheme,
albums,
),
icon: const Icon(Icons.download_rounded, size: 26),
color: headerScheme.onPrimaryContainer,
tooltip: context.l10n.discographyDownload,
),
),
],
],
],
],
),
@@ -6,6 +6,7 @@ import 'package:path/path.dart' as p;
import 'package:spotiflac_android/widgets/app_icon_button.dart';
import 'package:spotiflac_android/widgets/collection_scaffold.dart';
import 'package:spotiflac_android/widgets/expressive_button.dart';
import 'package:spotiflac_android/widgets/expressive_button_group.dart';
import 'package:spotiflac_android/theme/app_tokens.dart';
import 'package:spotiflac_android/widgets/track_card.dart';
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
@@ -623,12 +624,12 @@ class _LibraryTracksFolderScreenState
)
: null,
actions: entries.isNotEmpty
? Row(
mainAxisAlignment: MainAxisAlignment.center,
? ExpressiveButtonGroup(
flexible: const {0, 1},
spacing: 8,
children: [
Flexible(child: _buildPlayAllCenterButton(entries)),
const SizedBox(width: 8),
Flexible(child: _buildDownloadAllCenterButton(entries)),
_buildPlayAllCenterButton(entries),
_buildDownloadAllCenterButton(entries),
],
)
: null,
+24 -22
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart' show CupertinoIcons;
import 'package:spotiflac_android/screens/track_history_snapshot.dart';
import 'package:spotiflac_android/widgets/album_detail_header.dart';
import 'package:spotiflac_android/widgets/expressive_button_group.dart';
import 'package:spotiflac_android/screens/selection_mode_mixin.dart';
import 'package:spotiflac_android/widgets/collection_scaffold.dart';
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
@@ -356,29 +357,30 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen>
),
],
),
actions: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (context.isMornye) ...[
_buildLoveAllButton(),
const SizedBox(width: 16),
Flexible(
child: SizedBox(
width: 172,
child: _buildDownloadAllCenterButton(context),
),
actions: context.isMornye
? Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildLoveAllButton(),
const SizedBox(width: 16),
Flexible(
child: SizedBox(
width: 172,
child: _buildDownloadAllCenterButton(context),
),
),
const SizedBox(width: 16),
_buildAddToPlaylistButton(context),
],
)
: ExpressiveButtonGroup(
flexible: const {1},
children: [
_buildLoveAllButton(),
_buildDownloadAllCenterButton(context),
_buildAddToPlaylistButton(context),
],
),
const SizedBox(width: 16),
_buildAddToPlaylistButton(context),
] else ...[
_buildLoveAllButton(),
const SizedBox(width: 12),
Flexible(child: _buildDownloadAllCenterButton(context)),
const SizedBox(width: 12),
_buildAddToPlaylistButton(context),
],
],
),
);
}
+120 -100
View File
@@ -5,14 +5,17 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show RenderProxySliver;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/providers/runtime_profile_provider.dart';
import 'package:spotiflac_android/theme/app_motion.dart';
import 'package:spotiflac_android/theme/app_tokens.dart';
import 'package:spotiflac_android/theme/cover_palette.dart';
import 'package:spotiflac_android/theme/material_expressive.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/theme/mornye_icons.dart';
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
import 'package:spotiflac_android/widgets/app_icon.dart';
import 'package:spotiflac_android/widgets/app_icon_button.dart';
import 'package:spotiflac_android/widgets/expressive_button.dart';
import 'package:spotiflac_android/widgets/expressive_button_group.dart';
import 'package:spotiflac_android/widgets/expressive_icon_button.dart';
import 'package:spotiflac_android/utils/adaptive_layout.dart';
@@ -292,6 +295,34 @@ class AlbumDetailHeader extends StatelessWidget {
// keeps the familiar dark treatment — both tinted by the artwork.
final scrimColor = headerScheme.surface;
final onHeader = headerScheme.onSurface;
final expressive = materialExpressiveEnabled(context);
final fallbackMotion = AppMotionSpec(tokens.motionFast, Curves.linear);
final titleSlide = AppMotion.spatial(context, fallbackMotion);
final titleFade = AppMotion.effects(context, fallbackMotion);
Widget toolbarTitle = AnimatedOpacity(
duration: titleFade.duration,
curve: titleFade.curve,
opacity: showTitleInAppBar ? 1.0 : 0.0,
child: Text(
appBarTitle ?? title,
style: TextStyle(
color: onHeader,
fontWeight: FontWeight.w600,
fontSize: 16,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
);
if (expressive) {
// The collapsed title rises into the toolbar as the hero title leaves.
toolbarTitle = AnimatedSlide(
duration: titleSlide.duration,
curve: titleSlide.curve,
offset: showTitleInAppBar ? Offset.zero : const Offset(0, 0.6),
child: toolbarTitle,
);
}
return SliverAppBar(
expandedHeight: expandedHeight,
@@ -299,26 +330,73 @@ class AlbumDetailHeader extends StatelessWidget {
stretch: true,
backgroundColor: backgroundColor ?? headerScheme.surface,
surfaceTintColor: Colors.transparent,
title: AnimatedOpacity(
duration: tokens.motionFast,
opacity: showTitleInAppBar ? 1.0 : 0.0,
child: Text(
appBarTitle ?? title,
style: TextStyle(
color: onHeader,
fontWeight: FontWeight.w600,
fontSize: 16,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
title: toolbarTitle,
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio =
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3;
// Expressive headers shrink and fade the hero content with the
// scroll instead of hiding it at one threshold.
final contentProgress = Curves.easeOut.transform(
((collapseRatio - 0.3) / 0.5).clamp(0.0, 1.0),
);
final content = Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (coverBuilder != null) ...[
Builder(
builder: (context) {
final coverSize = (constraints.maxWidth * 0.5)
.clamp(150.0, 210.0)
.toDouble();
// Material uses the expressive extra-large corner.
final coverRadius = context.isMornye
? tokens.radiusControl
: 28.0;
return Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
plainArtworkHeader ? tokens.radiusCover : coverRadius,
),
boxShadow: [
BoxShadow(
color: headerScheme.shadow.withValues(alpha: 0.45),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(coverRadius),
child: coverBuilder!(context, coverSize),
),
);
},
),
const SizedBox(height: 20),
],
Text(
title,
style: TextStyle(
color: onHeader,
fontSize: _titleFontSize(),
fontWeight: FontWeight.bold,
height: 1.2,
),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
if (subtitle != null) ...[const SizedBox(height: 6), subtitle!],
if (meta != null) ...[const SizedBox(height: 12), meta!],
if (actions != null) ...[const SizedBox(height: 16), actions!],
],
);
return FlexibleSpaceBar(
collapseMode: CollapseMode.pin,
@@ -359,76 +437,23 @@ class AlbumDetailHeader extends StatelessWidget {
left: 20 + iosEdgeInset,
right: 20 + iosEdgeInset,
bottom: 40,
child: AnimatedOpacity(
duration: tokens.motionFast,
opacity: showContent ? 1.0 : 0.0,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (coverBuilder != null) ...[
Builder(
builder: (context) {
final coverSize = (constraints.maxWidth * 0.5)
.clamp(150.0, 210.0)
.toDouble();
return Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
plainArtworkHeader
? tokens.radiusCover
: tokens.radiusControl,
),
boxShadow: [
BoxShadow(
color: headerScheme.shadow.withValues(
alpha: 0.45,
),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(
tokens.radiusControl,
),
child: coverBuilder!(context, coverSize),
),
);
},
child: expressive
? IgnorePointer(
ignoring: contentProgress == 0,
child: Opacity(
opacity: contentProgress,
child: Transform.scale(
scale: 0.9 + 0.1 * contentProgress,
alignment: Alignment.bottomCenter,
child: content,
),
),
const SizedBox(height: 20),
],
Text(
title,
style: TextStyle(
color: onHeader,
fontSize: _titleFontSize(),
fontWeight: FontWeight.bold,
height: 1.2,
),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
)
: AnimatedOpacity(
duration: tokens.motionFast,
opacity: showContent ? 1.0 : 0.0,
child: content,
),
if (subtitle != null) ...[
const SizedBox(height: 6),
subtitle!,
],
if (meta != null) ...[
const SizedBox(height: 12),
meta!,
],
if (actions != null) ...[
const SizedBox(height: 16),
actions!,
],
],
),
),
),
],
),
@@ -572,27 +597,22 @@ class AlbumPlayActions extends StatelessWidget {
],
);
}
return Row(
mainAxisAlignment: MainAxisAlignment.center,
return ExpressiveButtonGroup(
flexible: const {0},
spacing: tokens.gapMd,
slotWidth: tokens.minTouchTarget,
children: [
Flexible(
child: ExpressiveButton(
onPressed: onPlay,
icon: AppIcon(isPlaying ? Icons.pause : Icons.play_arrow, size: 20),
style: FilledButton.styleFrom(
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
minimumSize: Size(0, tokens.minTouchTarget),
shape: const StadiumBorder(),
),
child: Text(
playLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
ExpressiveButton(
onPressed: onPlay,
icon: AppIcon(isPlaying ? Icons.pause : Icons.play_arrow, size: 20),
style: FilledButton.styleFrom(
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
minimumSize: Size(0, tokens.minTouchTarget),
shape: const StadiumBorder(),
),
child: Text(playLabel, maxLines: 1, overflow: TextOverflow.ellipsis),
),
SizedBox(width: tokens.gapMd),
ExpressiveIconButton(
tooltip: shuffleTooltip,
onPressed: onShuffle,
+131
View File
@@ -0,0 +1,131 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:material_3_expressive/material_3_expressive.dart';
import 'package:spotiflac_android/theme/app_motion.dart';
import 'package:spotiflac_android/theme/material_expressive.dart';
/// A row of header actions laid out as a Material 3 Expressive button group.
///
/// [flexible] actions share the free width; the others keep a [slotWidth]
/// box. Pressing an action widens it while its neighbours give way. Without
/// expressive motion the row keeps the centred, content-sized layout with
/// [Flexible] actions that it replaces.
class ExpressiveButtonGroup extends StatefulWidget {
const ExpressiveButtonGroup({
super.key,
required this.children,
this.flexible = const {},
this.spacing = 12,
this.slotWidth = 48,
});
final List<Widget> children;
/// Indices of [children] that fill the remaining width.
final Set<int> flexible;
final double spacing;
final double slotWidth;
@override
State<ExpressiveButtonGroup> createState() => _ExpressiveButtonGroupState();
}
/// Width an [ExpressiveButtonGroup] gives a fixed action, so an icon button
/// can stretch its container along with the squish.
class ExpressiveButtonGroupSlot extends InheritedWidget {
const ExpressiveButtonGroupSlot({
super.key,
required this.width,
required super.child,
});
final double width;
static double? widthOf(BuildContext context) => context
.dependOnInheritedWidgetOfExactType<ExpressiveButtonGroupSlot>()
?.width;
@override
bool updateShouldNotify(ExpressiveButtonGroupSlot oldWidget) =>
width != oldWidget.width;
}
class _ExpressiveButtonGroupState extends State<ExpressiveButtonGroup> {
static const _squishRatio = 0.15;
static final _pressSpring = SpringCurve(M3EMotion.expressiveSpatialPress);
int? _pressed;
void _press(int? index) {
if (_pressed != index) setState(() => _pressed = index);
}
@override
Widget build(BuildContext context) {
final children = widget.children;
final expressive = materialExpressiveEnabled(context);
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < children.length; i++) ...[
if (i > 0) SizedBox(width: widget.spacing),
if (!expressive)
widget.flexible.contains(i)
? Flexible(child: children[i])
: children[i]
else
_slot(i),
],
],
);
}
Widget _slot(int index) {
final pressed = _pressed;
final neighbour = pressed != null && (index - pressed).abs() == 1;
final child = Listener(
onPointerDown: (_) => _press(index),
onPointerUp: (_) => _press(null),
onPointerCancel: (_) => _press(null),
child: widget.children[index],
);
if (widget.flexible.contains(index)) {
// A flexible action grows into whatever its fixed neighbours yield; it
// only trades weight with another flexible action.
final weight = pressed == index
? 1 + _squishRatio
: neighbour && widget.flexible.contains(pressed)
? 1 - _squishRatio
: 1.0;
return TweenAnimationBuilder<double>(
tween: Tween(end: weight),
duration: _pressSpring.duration,
curve: _pressSpring,
builder: (context, weight, child) =>
Expanded(flex: math.max(1, (weight * 1000).round()), child: child!),
child: child,
);
}
final width =
widget.slotWidth *
(pressed == index
? 1 + _squishRatio
: neighbour
? 1 - _squishRatio
: 1.0);
return TweenAnimationBuilder<double>(
tween: Tween(end: width),
duration: _pressSpring.duration,
curve: _pressSpring,
builder: (context, width, child) {
final slot = math.max(0.0, width);
return SizedBox(
width: slot,
child: ExpressiveButtonGroupSlot(width: slot, child: child!),
);
},
child: child,
);
}
}
+6 -1
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:material_3_expressive/material_3_expressive.dart';
import 'package:spotiflac_android/theme/material_expressive.dart';
import 'package:spotiflac_android/widgets/app_icon.dart';
import 'package:spotiflac_android/widgets/expressive_button_group.dart';
/// Compact expressive transport/header actions with the same accessible target
/// and tooltip as the standard Material fallback.
@@ -68,7 +69,11 @@ class ExpressiveIconButton extends StatelessWidget {
semanticLabel: tooltip,
onPressed: onPressed,
isSelected: selected,
visualSize: Size.square(size),
// Widens with an expressive button group's squish.
visualSize: Size(
ExpressiveButtonGroupSlot.widthOf(context) ?? size,
size,
),
variant: backgroundColor == null
? M3EIconButtonVariant.standard
: M3EIconButtonVariant.filled,
+64
View File
@@ -17,6 +17,7 @@ import 'package:spotiflac_android/widgets/app_choice_chip.dart';
import 'package:spotiflac_android/widgets/app_content_card.dart';
import 'package:spotiflac_android/widgets/app_switch.dart';
import 'package:spotiflac_android/widgets/expressive_button.dart';
import 'package:spotiflac_android/widgets/expressive_button_group.dart';
import 'package:spotiflac_android/widgets/expressive_icon_button.dart';
import 'package:spotiflac_android/widgets/expressive_navigation_bar.dart';
import 'package:spotiflac_android/widgets/selection_action_button.dart';
@@ -496,4 +497,67 @@ void main() {
expect(find.byType(M3ECard), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('button group squishes neighbours and keeps a plain fallback', (
tester,
) async {
var calls = 0;
Widget group() => SizedBox(
width: 360,
child: ExpressiveButtonGroup(
flexible: const {1},
children: [
ExpressiveIconButton(
icon: const Icon(Icons.favorite_border),
tooltip: 'Love',
backgroundColor: Colors.grey,
onPressed: () => calls++,
),
ExpressiveButton(
onPressed: () => calls++,
child: const Text('Download'),
),
ExpressiveIconButton(
icon: const Icon(Icons.playlist_add),
tooltip: 'Add',
backgroundColor: Colors.grey,
onPressed: () => calls++,
),
],
),
);
await tester.pumpWidget(_host(group()));
await tester.pumpAndSettle();
final slot = find.byType(ExpressiveButtonGroupSlot).first;
final download = find.byType(M3EButton);
final restingSlot = tester.getSize(slot).width;
final restingDownload = tester.getSize(download).width;
expect(restingSlot, 48);
// The flexible action fills what the fixed slots and gaps leave.
expect(restingDownload, closeTo(360 - 2 * 48 - 2 * 12, 1));
final gesture = await tester.startGesture(
tester.getCenter(find.byTooltip('Love')),
);
// Stay under the long-press delay so the release still taps.
await tester.pump();
await tester.pump(const Duration(milliseconds: 150));
expect(tester.getSize(slot).width, greaterThan(restingSlot));
expect(tester.getSize(download).width, lessThan(restingDownload));
await gesture.up();
await tester.pumpAndSettle();
expect(tester.getSize(slot).width, restingSlot);
expect(calls, 1);
await tester.pumpWidget(_host(group(), reduceMotion: true));
await tester.pumpAndSettle();
expect(find.byType(ExpressiveButtonGroupSlot), findsNothing);
expect(
tester.getSize(find.byType(FilledButton)).width,
lessThan(restingDownload),
);
await tester.tap(find.text('Download'));
expect(calls, 2);
expect(tester.takeException(), isNull);
});
}