mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 23:11:45 +02:00
feat(material): animate header action groups and collapse
This commit is contained in:
1 parent
736cfcd081
commit
56e2eb8589
9 files changed
+477
-195
No files matched your search
@@ -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),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user