diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index e0cafdc7..f84ad180 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -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 _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), ], ), diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 2e4a0776..6fae7956 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -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'; diff --git a/lib/screens/artist_screen_widgets.dart b/lib/screens/artist_screen_widgets.dart index b507b52d..28206dd0 100644 --- a/lib/screens/artist_screen_widgets.dart +++ b/lib/screens/artist_screen_widgets.dart @@ -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, + ), + ), + ], + ], ], ], ), diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index f567a245..0afad7c4 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -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, diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 61b488a3..905082c9 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -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 ), ], ), - 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), - ], - ], - ), ); } diff --git a/lib/widgets/album_detail_header.dart b/lib/widgets/album_detail_header.dart index c0a5699a..59e07c2b 100644 --- a/lib/widgets/album_detail_header.dart +++ b/lib/widgets/album_detail_header.dart @@ -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, diff --git a/lib/widgets/expressive_button_group.dart b/lib/widgets/expressive_button_group.dart new file mode 100644 index 00000000..9a5bd41b --- /dev/null +++ b/lib/widgets/expressive_button_group.dart @@ -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 children; + + /// Indices of [children] that fill the remaining width. + final Set flexible; + final double spacing; + final double slotWidth; + + @override + State 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() + ?.width; + + @override + bool updateShouldNotify(ExpressiveButtonGroupSlot oldWidget) => + width != oldWidget.width; +} + +class _ExpressiveButtonGroupState extends State { + 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( + 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( + 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, + ); + } +} diff --git a/lib/widgets/expressive_icon_button.dart b/lib/widgets/expressive_icon_button.dart index eb0d34fe..ded9ef82 100644 --- a/lib/widgets/expressive_icon_button.dart +++ b/lib/widgets/expressive_icon_button.dart @@ -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, diff --git a/test/material_expressive_test.dart b/test/material_expressive_test.dart index 4e1c8616..fe5e7b59 100644 --- a/test/material_expressive_test.dart +++ b/test/material_expressive_test.dart @@ -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); + }); }