diff --git a/lib/screens/artist_concerts_screen.dart b/lib/screens/artist_concerts_screen.dart index 04d6aaa7..97bfa246 100644 --- a/lib/screens/artist_concerts_screen.dart +++ b/lib/screens/artist_concerts_screen.dart @@ -8,6 +8,7 @@ import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/widgets/album_detail_header.dart'; +import 'package:spotiflac_android/widgets/app_shape.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; @@ -78,7 +79,8 @@ class _ArtistConcertsScreenState extends State { sliver: SliverToBoxAdapter( child: Row( children: [ - ClipOval( + AppShapeClip( + kind: AppShapes.artist, child: SizedBox.square( dimension: 96, child: widget.coverUrl == null || widget.coverUrl!.isEmpty diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 7f3f5009..2e4a0776 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter/cupertino.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:spotiflac_android/widgets/app_icon_button.dart'; +import 'package:spotiflac_android/widgets/app_progress_indicator.dart'; import 'package:spotiflac_android/widgets/expressive_button.dart'; import 'package:spotiflac_android/theme/mornye_icons.dart'; import 'package:spotiflac_android/widgets/mornye_artist_header.dart'; diff --git a/lib/screens/artist_screen_widgets.dart b/lib/screens/artist_screen_widgets.dart index eb586fee..b507b52d 100644 --- a/lib/screens/artist_screen_widgets.dart +++ b/lib/screens/artist_screen_widgets.dart @@ -1188,10 +1188,10 @@ class _FetchingProgressDialogState extends State<_FetchingProgressDialog> { child: Stack( alignment: Alignment.center, children: [ - CircularProgressIndicator( + AppProgressIndicator.circular( value: progress > 0 ? progress : null, strokeWidth: 4, - backgroundColor: colorScheme.surfaceContainerHighest, + trackColor: colorScheme.surfaceContainerHighest, ), Icon(Icons.library_music, color: colorScheme.primary, size: 24), ], @@ -1214,9 +1214,9 @@ class _FetchingProgressDialogState extends State<_FetchingProgressDialog> { const SizedBox(height: 8), ClipRRect( borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( + child: AppProgressIndicator.linear( value: progress > 0 ? progress : null, - backgroundColor: colorScheme.surfaceContainerHighest, + trackColor: colorScheme.surfaceContainerHighest, minHeight: 6, ), ), diff --git a/lib/screens/concert_detail_screen.dart b/lib/screens/concert_detail_screen.dart index 41bed064..f2467b74 100644 --- a/lib/screens/concert_detail_screen.dart +++ b/lib/screens/concert_detail_screen.dart @@ -16,6 +16,7 @@ import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; import 'package:spotiflac_android/widgets/app_icon_button.dart'; +import 'package:spotiflac_android/widgets/app_shape.dart'; import 'package:spotiflac_android/widgets/expressive_button.dart'; class ConcertDetailScreen extends StatefulWidget { @@ -190,7 +191,8 @@ class _ConcertDetailScreenState extends State { Stack( clipBehavior: Clip.none, children: [ - ClipOval( + AppShapeClip( + kind: AppShapes.artist, child: SizedBox.square( dimension: photoSize, child: source == null diff --git a/lib/screens/favorite_artists_screen.dart b/lib/screens/favorite_artists_screen.dart index 4a2431d8..8a09faee 100644 --- a/lib/screens/favorite_artists_screen.dart +++ b/lib/screens/favorite_artists_screen.dart @@ -10,6 +10,7 @@ import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/widgets/app_icon_button.dart'; +import 'package:spotiflac_android/widgets/app_shape.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; class FavoriteArtistsScreen extends ConsumerWidget { @@ -153,7 +154,8 @@ class _ArtistThumbnail extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final imageUrl = artist.imageUrl; - return ClipOval( + return AppShapeClip( + kind: AppShapes.artist, child: imageUrl != null && imageUrl.isNotEmpty ? CachedNetworkImage( imageUrl: imageUrl, diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 851c1b8b..44437585 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -9,6 +9,11 @@ import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/app_switch.dart'; import 'package:spotiflac_android/widgets/app_choice_chip.dart'; import 'package:spotiflac_android/widgets/app_loading_indicator.dart'; +import 'package:spotiflac_android/widgets/app_progress_indicator.dart'; +import 'package:material_3_expressive/material_3_expressive.dart'; +import 'package:spotiflac_android/theme/material_expressive.dart'; +import 'package:spotiflac_android/widgets/app_refresh_indicator.dart'; +import 'package:spotiflac_android/widgets/app_shape.dart'; import 'package:spotiflac_android/widgets/expressive_button.dart'; import 'package:spotiflac_android/widgets/app_icon_button.dart'; import 'package:spotiflac_android/theme/mornye_icons.dart'; @@ -913,7 +918,7 @@ class _HomeTabState extends ConsumerState }, behavior: HitTestBehavior.translucent, child: Scaffold( - body: RefreshIndicator( + body: AppRefreshIndicator( onRefresh: () => ref.read(exploreProvider.notifier).refresh(), notificationPredicate: (notification) => showExplore, child: CustomScrollView( @@ -1205,13 +1210,10 @@ class _HomeTabState extends ConsumerState child: Column( children: [ SizedBox(height: screenHeight * 0.06), - Container( - width: 96, - height: 96, - decoration: BoxDecoration( - color: colorScheme.primary, - shape: BoxShape.circle, - ), + AppShapeBadge( + kind: AppShapes.brand, + size: 96, + color: colorScheme.primary, child: Image.asset( 'assets/images/logo-transparent.png', color: colorScheme.onPrimary, @@ -1266,19 +1268,84 @@ class _HomeTabState extends ConsumerState final itemCount = items.length < 10 ? items.length : 10; final coverSize = _recentDownloadCoverSize(context); final rowHeight = _recentDownloadsRowHeight(context); + final header = Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + context.l10n.homeRecent, + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ); + + if (materialExpressiveEnabled(context)) { + final recent = items.take(itemCount).toList(growable: false); + final carouselHeight = coverSize * 1.6; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + header, + MaterialExpressiveScope( + child: M3ECarousel( + type: M3ECarouselType.contained, + height: carouselHeight, + freeScroll: true, + children: [ + for (final (index, item) in recent.indexed) + M3ECarouselItem( + semanticLabel: context.l10n.a11yOpenTrackByArtist( + item.trackName, + item.artistName, + ), + // Each slot changes width while scrolling; cover-fit a + // square decode so every slot stays filled. + image: DownloadableCover( + coverUrl: item.coverUrl, + baseName: '${item.artistName} - ${item.trackName}', + child: SizedBox.expand( + child: FittedBox( + fit: BoxFit.cover, + clipBehavior: Clip.hardEdge, + child: _DownloadedOrRemoteCover( + downloadedFilePath: item.filePath, + imageUrl: item.coverUrl, + width: carouselHeight, + height: carouselHeight, + borderRadius: BorderRadius.zero, + fallbackIcon: Icons.music_note, + fallbackIconSize: 32, + colorScheme: colorScheme, + ), + ), + ), + ), + subtitle: Text( + item.trackName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + showScrim: const M3ECarouselScrim.gradient(), + onTap: () => _navigateToMetadataScreen( + item, + navigationItems: recent, + navigationIndex: index, + ), + ), + ], + ), + ), + ], + ); + } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Text( - context.l10n.homeRecent, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), + header, SizedBox( height: rowHeight, child: ListView.builder( diff --git a/lib/screens/home_tab_search_results.dart b/lib/screens/home_tab_search_results.dart index 478232c4..5e47b629 100644 --- a/lib/screens/home_tab_search_results.dart +++ b/lib/screens/home_tab_search_results.dart @@ -117,13 +117,10 @@ extension _HomeTabSearchResultsUI on _HomeTabState { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Container( - width: 86, - height: 86, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: colorScheme.surfaceContainerHighest, - ), + AppShapeBadge( + kind: AppShapes.badge, + size: 86, + color: colorScheme.surfaceContainerHighest, child: Icon( Icons.manage_search, size: 46, @@ -343,7 +340,7 @@ extension _HomeTabSearchResultsUI on _HomeTabState { const SliverToBoxAdapter( child: Padding( padding: EdgeInsets.symmetric(horizontal: 16), - child: LinearProgressIndicator(), + child: AppProgressIndicator.linear(), ), ), if (showEmptySearchResult && !hasActualData) diff --git a/lib/screens/home_tab_widgets.dart b/lib/screens/home_tab_widgets.dart index 3d0f3a02..5b4e9a61 100644 --- a/lib/screens/home_tab_widgets.dart +++ b/lib/screens/home_tab_widgets.dart @@ -445,6 +445,19 @@ class _CollectionItemWidget extends StatelessWidget { if (isArtist) placeholderIcon = Icons.person; final hasCover = item.coverUrl != null && item.coverUrl!.isNotEmpty; + final image = hasCover + ? CachedCoverImage( + imageUrl: item.coverUrl!, + width: 56, + height: 56, + fit: BoxFit.cover, + ) + : Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon(placeholderIcon, color: colorScheme.onSurfaceVariant), + ); final cover = Semantics( label: '${context.l10n.dialogDownload} ' @@ -455,25 +468,13 @@ class _CollectionItemWidget extends StatelessWidget { behavior: HitTestBehavior.opaque, excludeFromSemantics: true, onLongPress: hasCover ? onSaveCover : null, - child: ClipRRect( - borderRadius: BorderRadius.circular(isArtist ? 28 : 10), - child: hasCover - ? CachedCoverImage( - imageUrl: item.coverUrl!, - width: 56, - height: 56, - fit: BoxFit.cover, - ) - : Container( - width: 56, - height: 56, - color: colorScheme.surfaceContainerHighest, - child: Icon( - placeholderIcon, - color: colorScheme.onSurfaceVariant, - ), - ), - ), + child: isArtist + ? AppShapeClip( + kind: AppShapes.artist, + fallbackRadius: BorderRadius.circular(28), + child: image, + ) + : ClipRRect(borderRadius: BorderRadius.circular(10), child: image), ), ); return Column( diff --git a/lib/screens/listening_statistics_screen.dart b/lib/screens/listening_statistics_screen.dart index 3e4e06ce..2d7d8460 100644 --- a/lib/screens/listening_statistics_screen.dart +++ b/lib/screens/listening_statistics_screen.dart @@ -7,6 +7,8 @@ import 'package:spotiflac_android/services/listening_statistics.dart'; import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:spotiflac_android/widgets/app_choice_chip.dart'; import 'package:spotiflac_android/widgets/app_icon_button.dart'; +import 'package:spotiflac_android/widgets/app_refresh_indicator.dart'; +import 'package:spotiflac_android/widgets/app_shape.dart'; import 'package:spotiflac_android/widgets/app_loading_indicator.dart'; import 'package:spotiflac_android/widgets/expressive_button.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; @@ -80,7 +82,7 @@ class _ListeningStatisticsScreenState final summary = ref.watch(listeningSummaryProvider(period)); final colors = Theme.of(context).colorScheme; return Scaffold( - body: RefreshIndicator( + body: AppRefreshIndicator( onRefresh: () => ref.refresh(listeningSummaryProvider(period).future), child: CustomScrollView( slivers: [ @@ -281,7 +283,8 @@ class _ListeningStatisticsScreenState for (final artist in artists.take(10)) ListTile( leading: ExcludeSemantics( - child: ClipOval( + child: AppShapeClip( + kind: AppShapes.artist, child: SizedBox.square( dimension: 48, child: artistArtwork[artist.key] == null diff --git a/lib/screens/network_storage_screen.dart b/lib/screens/network_storage_screen.dart index f95bdc96..bc7bef2d 100644 --- a/lib/screens/network_storage_screen.dart +++ b/lib/screens/network_storage_screen.dart @@ -15,6 +15,7 @@ import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; import 'package:spotiflac_android/widgets/app_action_button.dart'; import 'package:spotiflac_android/widgets/app_loading_indicator.dart'; +import 'package:spotiflac_android/widgets/app_progress_indicator.dart'; import 'package:spotiflac_android/widgets/expressive_button.dart'; import 'package:spotiflac_android/widgets/expressive_icon_button.dart'; import 'package:spotiflac_android/widgets/mornye_chrome.dart'; @@ -331,11 +332,12 @@ class _NetworkStorageScreenState extends ConsumerState { context.l10n.networkDownloadDescription, style: Theme.of(context).textTheme.bodySmall, ), - if (_selectingDestination) const LinearProgressIndicator(), + if (_selectingDestination) + const AppProgressIndicator.linear(), if (_adding) const Padding( padding: EdgeInsets.only(top: 8), - child: LinearProgressIndicator(), + child: AppProgressIndicator.linear(), ), ], ), diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 4315e2d7..f1ab85fa 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -14,6 +14,7 @@ import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/app_icon.dart'; import 'package:spotiflac_android/widgets/app_icon_button.dart'; import 'package:spotiflac_android/widgets/app_loading_indicator.dart'; +import 'package:spotiflac_android/widgets/app_progress_indicator.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; import 'package:spotiflac_android/widgets/expressive_button.dart'; import 'package:spotiflac_android/widgets/app_search_field.dart'; diff --git a/lib/screens/queue_tab_collection_items.dart b/lib/screens/queue_tab_collection_items.dart index 5e785b84..de0fe7be 100644 --- a/lib/screens/queue_tab_collection_items.dart +++ b/lib/screens/queue_tab_collection_items.dart @@ -51,13 +51,11 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { SizedBox( width: 34, height: 34, - child: CircularProgressIndicator( + child: AppProgressIndicator.circular( value: visualProgress, strokeWidth: 3, color: Colors.white, - backgroundColor: Colors.white.withValues( - alpha: 0.25, - ), + trackColor: Colors.white.withValues(alpha: 0.25), ), ), const SizedBox(height: 6), @@ -75,10 +73,10 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { : SizedBox( width: 34, height: 34, - child: CircularProgressIndicator( + child: AppProgressIndicator.circular( strokeWidth: 3, color: Colors.white, - backgroundColor: Colors.white.withValues(alpha: 0.25), + trackColor: Colors.white.withValues(alpha: 0.25), ), ), ), diff --git a/lib/screens/queue_tab_item_widgets.dart b/lib/screens/queue_tab_item_widgets.dart index effd6107..805565c1 100644 --- a/lib/screens/queue_tab_item_widgets.dart +++ b/lib/screens/queue_tab_item_widgets.dart @@ -335,10 +335,10 @@ extension _QueueTabItemWidgets on _QueueTabState { child: SizedBox( width: coverSize * 0.6, height: coverSize * 0.6, - child: CircularProgressIndicator( + child: AppProgressIndicator.circular( strokeWidth: 3, color: Colors.white, - backgroundColor: Colors.white.withValues(alpha: 0.25), + trackColor: Colors.white.withValues(alpha: 0.25), ), ), ) @@ -351,11 +351,11 @@ extension _QueueTabItemWidgets on _QueueTabState { child: SizedBox( width: coverSize * 0.6, height: coverSize * 0.6, - child: CircularProgressIndicator( + child: AppProgressIndicator.circular( value: visualProgress, strokeWidth: 3, color: Colors.white, - backgroundColor: Colors.white.withValues(alpha: 0.25), + trackColor: Colors.white.withValues(alpha: 0.25), ), ), ), @@ -471,7 +471,7 @@ extension _QueueTabItemWidgets on _QueueTabState { child: Stack( alignment: Alignment.center, children: [ - CircularProgressIndicator( + AppProgressIndicator.circular( strokeWidth: 3, color: colorScheme.tertiary, ), diff --git a/lib/screens/repo_tab.dart b/lib/screens/repo_tab.dart index 52c49670..16f8da32 100644 --- a/lib/screens/repo_tab.dart +++ b/lib/screens/repo_tab.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:spotiflac_android/widgets/app_snack_bar.dart'; import 'package:flutter/cupertino.dart' show CupertinoIcons, CupertinoTextField; import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/widgets/app_refresh_indicator.dart'; import 'package:spotiflac_android/widgets/extension_row.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; import 'package:spotiflac_android/widgets/app_search_field.dart'; @@ -94,7 +95,7 @@ class _RepoTabState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; return Scaffold( - body: RefreshIndicator( + body: AppRefreshIndicator( onRefresh: () => ref.read(repoProvider.notifier).refresh(forceRefresh: true), child: CustomScrollView( diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index 82dfb22d..44ce7055 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -19,6 +19,7 @@ import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/app_icon.dart'; import 'package:spotiflac_android/widgets/app_loading_indicator.dart'; +import 'package:spotiflac_android/widgets/app_progress_indicator.dart'; import 'package:spotiflac_android/widgets/expressive_button.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; import 'package:spotiflac_android/widgets/ios_library_folder_sheet.dart'; @@ -1369,13 +1370,13 @@ class _LibraryHeroCard extends StatelessWidget { ], if (isScanning) ...[ const SizedBox(height: 16), - LinearProgressIndicator( + AppProgressIndicator.linear( value: showIndeterminateProgress ? null : scanTotalFiles > 0 ? scanProgress / 100 : 0, - backgroundColor: colorScheme.surfaceContainerHighest, + trackColor: colorScheme.surfaceContainerHighest, color: colorScheme.primary, borderRadius: BorderRadius.circular(4), ), @@ -1541,9 +1542,9 @@ class _ScanProgressTile extends StatelessWidget { ], ), const SizedBox(height: 8), - LinearProgressIndicator( + AppProgressIndicator.linear( value: showIndeterminateProgress ? null : progressValue, - backgroundColor: colorScheme.surfaceContainerHighest, + trackColor: colorScheme.surfaceContainerHighest, color: colorScheme.primary, borderRadius: BorderRadius.circular(4), ), diff --git a/lib/screens/weekly_releases_screen.dart b/lib/screens/weekly_releases_screen.dart index 373a5d49..8d4aec01 100644 --- a/lib/screens/weekly_releases_screen.dart +++ b/lib/screens/weekly_releases_screen.dart @@ -7,6 +7,7 @@ import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/widgets/app_choice_chip.dart'; import 'package:spotiflac_android/widgets/app_loading_indicator.dart'; +import 'package:spotiflac_android/widgets/app_refresh_indicator.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; @@ -39,7 +40,7 @@ class _WeeklyReleasesScreenState extends ConsumerState { Widget build(BuildContext context) { final feed = ref.watch(weeklyReleasesProvider(_refresh)); return Scaffold( - body: RefreshIndicator( + body: AppRefreshIndicator( onRefresh: _reload, child: CustomScrollView( physics: const AlwaysScrollableScrollPhysics(), diff --git a/lib/services/music_player_runtime.dart b/lib/services/music_player_runtime.dart index 6f18f4dc..5b0ce728 100644 --- a/lib/services/music_player_runtime.dart +++ b/lib/services/music_player_runtime.dart @@ -95,6 +95,9 @@ class PlaybackDependencies { config: const AudioServiceConfig( androidNotificationChannelId: 'com.zarz.spotiflac.playback', androidNotificationChannelName: 'Playback', + // System UI draws only the small icon's alpha. The opaque launcher + // default became a blank dot in the status bar and output switcher. + androidNotificationIcon: 'drawable/ic_launcher_monochrome', androidNotificationOngoing: true, androidStopForegroundOnPause: true, ), diff --git a/lib/utils/adaptive_layout.dart b/lib/utils/adaptive_layout.dart index 22f5ed57..0c540fdd 100644 --- a/lib/utils/adaptive_layout.dart +++ b/lib/utils/adaptive_layout.dart @@ -12,12 +12,13 @@ bool useNavigationRailForWidth(double width) => /// Explicit horizontal safe spacing for controls drawn in full-bleed detail /// headers. iOS reports no left/right safe-area inset in portrait, so toolbar -/// buttons otherwise land only a few pixels from the device edge. +/// buttons otherwise land only a few pixels from the device edge. Filled +/// expressive circles need the same breathing room on Android. double detailHeaderEdgeInset(BuildContext context) { final platform = Theme.of(context).platform; return platform == TargetPlatform.iOS || platform == TargetPlatform.macOS ? 12 - : 0; + : 8; } /// Visual scale for touch-first tablet surfaces. Large iPads have enough diff --git a/lib/widgets/album_detail_header.dart b/lib/widgets/album_detail_header.dart index dffc2be2..c0a5699a 100644 --- a/lib/widgets/album_detail_header.dart +++ b/lib/widgets/album_detail_header.dart @@ -284,8 +284,8 @@ class AlbumDetailHeader extends StatelessWidget { final plainArtworkHeader = context.isMornye && coverBuilder != null; // iOS does not add horizontal safe-area padding in portrait. Give toolbar // controls and header content an explicit inset so circular actions do not - // sit against the glass edge on either iPhone or iPad. Android retains its - // existing spacing. + // sit against the glass edge on either iPhone or iPad. Android uses a + // smaller inset for the same reason. final iosEdgeInset = detailHeaderEdgeInset(context); // Scrim and gradient are drawn from the palette surface instead of black, // so a light theme gets a light header with dark text and a dark theme @@ -441,19 +441,22 @@ class AlbumDetailHeader extends StatelessWidget { padding: EdgeInsets.only(left: iosEdgeInset), child: leading ?? - AppIconButton.filledTonal( - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: AppIcon( - context.isMornye ? Icons.chevron_left : Icons.arrow_back, - ), - style: IconButton.styleFrom( - minimumSize: Size.square(tokens.minTouchTarget), - backgroundColor: headerScheme.surfaceContainerHigh.withValues( - alpha: 0.75, + // Centered so the tight leading slot cannot stretch the circle. + Center( + child: AppIconButton.filledTonal( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + icon: AppIcon( + context.isMornye ? Icons.chevron_left : Icons.arrow_back, ), - foregroundColor: headerScheme.onSurfaceVariant, + style: IconButton.styleFrom( + minimumSize: Size.square(tokens.minTouchTarget), + backgroundColor: headerScheme.surfaceContainerHigh.withValues( + alpha: 0.75, + ), + foregroundColor: headerScheme.onSurfaceVariant, + ), + onPressed: () => Navigator.pop(context), ), - onPressed: () => Navigator.pop(context), ), ), actionsPadding: EdgeInsets.only(right: iosEdgeInset), diff --git a/lib/widgets/app_progress_indicator.dart b/lib/widgets/app_progress_indicator.dart new file mode 100644 index 00000000..6fb88133 --- /dev/null +++ b/lib/widgets/app_progress_indicator.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:material_3_expressive/material_3_expressive.dart'; +import 'package:spotiflac_android/theme/material_expressive.dart'; + +/// Task progress (downloads, scans, updates), determinate or not. +/// +/// The Material theme draws the expressive wavy indicators. Mornye and +/// reduced motion keep Flutter's flat indicators with the same parameters. +class AppProgressIndicator extends StatelessWidget { + const AppProgressIndicator.linear({ + super.key, + this.value, + this.color, + this.trackColor, + this.minHeight, + this.borderRadius, + this.semanticsLabel, + }) : strokeWidth = null, + strokeCap = null, + _circular = false; + + /// Fills the smaller side of its parent, or 48dp when unbounded. + const AppProgressIndicator.circular({ + super.key, + this.value, + this.color, + this.trackColor, + this.strokeWidth, + this.strokeCap, + this.semanticsLabel, + }) : minHeight = null, + borderRadius = null, + _circular = true; + + /// Progress in `0..1`, or null for indeterminate. + final double? value; + final Color? color; + final Color? trackColor; + + /// Height of the flat linear fallback; the wavy track keeps its own height. + final double? minHeight; + final BorderRadiusGeometry? borderRadius; + final double? strokeWidth; + + /// Cap of the flat circular fallback; the wavy stroke is always round. + final StrokeCap? strokeCap; + final String? semanticsLabel; + final bool _circular; + + @override + Widget build(BuildContext context) { + final value = this.value?.clamp(0.0, 1.0); + if (!materialExpressiveEnabled(context)) { + return _circular + ? CircularProgressIndicator( + value: value, + color: color, + backgroundColor: trackColor, + strokeWidth: strokeWidth, + strokeCap: strokeCap, + semanticsLabel: semanticsLabel, + ) + : LinearProgressIndicator( + value: value, + color: color, + backgroundColor: trackColor, + minHeight: minHeight, + borderRadius: borderRadius, + semanticsLabel: semanticsLabel, + ); + } + final scheme = Theme.of(context).colorScheme; + if (!_circular) { + return MaterialExpressiveScope( + child: M3EProgressIndicator.linearWavy( + value: value, + color: color ?? scheme.primary, + trackColor: trackColor, + semanticsLabel: semanticsLabel, + ), + ); + } + return LayoutBuilder( + builder: (context, constraints) { + final side = constraints.biggest.shortestSide; + return MaterialExpressiveScope( + child: M3EProgressIndicator.circularWavy( + value: value, + size: side.isFinite ? side : null, + color: color ?? scheme.primary, + trackColor: trackColor, + strokeWidth: strokeWidth, + trackStrokeWidth: strokeWidth, + semanticsLabel: semanticsLabel, + ), + ); + }, + ); + } +} diff --git a/lib/widgets/app_refresh_indicator.dart b/lib/widgets/app_refresh_indicator.dart new file mode 100644 index 00000000..341cde26 --- /dev/null +++ b/lib/widgets/app_refresh_indicator.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:material_3_expressive/material_3_expressive.dart'; +import 'package:spotiflac_android/theme/material_expressive.dart'; + +/// Pull-to-refresh with the expressive shape-morphing indicator on Material. +/// Mornye and reduced motion keep Flutter's [RefreshIndicator]. +class AppRefreshIndicator extends StatelessWidget { + const AppRefreshIndicator({ + super.key, + required this.onRefresh, + required this.child, + this.notificationPredicate = defaultScrollNotificationPredicate, + }); + + final RefreshCallback onRefresh; + final ScrollNotificationPredicate notificationPredicate; + final Widget child; + + @override + Widget build(BuildContext context) { + if (!materialExpressiveEnabled(context)) { + return RefreshIndicator( + onRefresh: onRefresh, + notificationPredicate: notificationPredicate, + child: child, + ); + } + return MaterialExpressiveScope( + child: M3ERefreshIndicator.contained( + onRefresh: onRefresh, + notificationPredicate: notificationPredicate, + child: child, + ), + ); + } +} diff --git a/lib/widgets/app_shape.dart b/lib/widgets/app_shape.dart new file mode 100644 index 00000000..c4d577f2 --- /dev/null +++ b/lib/widgets/app_shape.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:material_3_expressive/material_3_expressive.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; + +/// Expressive shapes used across the Material theme. +abstract final class AppShapes { + /// Artist portraits, from list thumbnails to profile headers. + static const artist = M3EShapeKind.cookie9Sided; + + /// Decorative badges behind empty-state and branding glyphs. + static const badge = M3EShapeKind.softBurst; + static const brand = M3EShapeKind.cookie12Sided; +} + +/// Clips [child] to an expressive [kind] on the Material theme. +/// +/// Mornye keeps its own geometry: [fallbackRadius], or an oval when null. +class AppShapeClip extends StatelessWidget { + const AppShapeClip({ + super.key, + required this.kind, + required this.child, + this.fallbackRadius, + }); + + final M3EShapeKind kind; + final BorderRadius? fallbackRadius; + final Widget child; + + @override + Widget build(BuildContext context) { + if (!context.isMornye) { + return ClipPath(clipper: M3EShapeClipper(kind), child: child); + } + final fallbackRadius = this.fallbackRadius; + return fallbackRadius == null + ? ClipOval(child: child) + : ClipRRect(borderRadius: fallbackRadius, child: child); + } +} + +/// A filled expressive [kind] behind a centered icon or logo. +/// +/// Mornye draws the same fill as a circle. +class AppShapeBadge extends StatelessWidget { + const AppShapeBadge({ + super.key, + required this.kind, + required this.size, + required this.color, + required this.child, + }); + + final M3EShapeKind kind; + final double size; + final Color color; + final Widget child; + + @override + Widget build(BuildContext context) => SizedBox.square( + dimension: size, + child: AppShapeClip( + kind: kind, + child: ColoredBox( + color: color, + child: Center(child: child), + ), + ), + ); +} diff --git a/lib/widgets/app_sliver_header.dart b/lib/widgets/app_sliver_header.dart index ec7191b4..a1fcf348 100644 --- a/lib/widgets/app_sliver_header.dart +++ b/lib/widgets/app_sliver_header.dart @@ -63,15 +63,30 @@ class AppSliverHeader extends StatelessWidget { padding: EdgeInsets.only(left: edgeInset), child: leading ?? - AppIconButton( - tooltip: MaterialLocalizations.of( - context, - ).backButtonTooltip, - icon: AppIcon( - context.isMornye ? Icons.chevron_left : Icons.arrow_back, - ), - onPressed: () => Navigator.pop(context), - ), + (context.isMornye + ? AppIconButton( + tooltip: MaterialLocalizations.of( + context, + ).backButtonTooltip, + icon: const AppIcon(Icons.chevron_left), + onPressed: () => Navigator.pop(context), + ) + // Expressive app bars hold navigation in a tonal circle. + : AppIconButton.filledTonal( + tooltip: MaterialLocalizations.of( + context, + ).backButtonTooltip, + icon: const AppIcon(Icons.arrow_back), + style: ButtonStyle( + backgroundColor: WidgetStatePropertyAll( + colorScheme.surfaceContainerHighest, + ), + foregroundColor: WidgetStatePropertyAll( + colorScheme.onSurface, + ), + ), + onPressed: () => Navigator.pop(context), + )), ) : null, actionsPadding: EdgeInsets.only(right: edgeInset), @@ -104,7 +119,7 @@ class AppSliverHeader extends StatelessWidget { tokens.headerCollapsedTitleSize) * expandRatio; - return FlexibleSpaceBar( + final bar = FlexibleSpaceBar( centerTitle: false, expandedTitleScale: 1.0, titlePadding: EdgeInsets.only( @@ -126,6 +141,17 @@ class AppSliverHeader extends StatelessWidget { ), ), ); + if (context.isMornye) return bar; + // Collapsed expressive bars sit on a container tone, like a + // scrolled-under app bar, and fade back to the surface on expand. + return ColoredBox( + color: Color.lerp( + colorScheme.surfaceContainer, + colorScheme.surface, + expandRatio, + )!, + child: bar, + ); }, ), ); diff --git a/lib/widgets/batch_progress_dialog.dart b/lib/widgets/batch_progress_dialog.dart index 16a550cc..a2eab43f 100644 --- a/lib/widgets/batch_progress_dialog.dart +++ b/lib/widgets/batch_progress_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/widgets/app_progress_indicator.dart'; class _BatchProgress { final int current; @@ -131,10 +132,10 @@ class _BatchProgressDialogState extends State { child: Stack( alignment: Alignment.center, children: [ - CircularProgressIndicator( + AppProgressIndicator.circular( value: progress > 0 ? progress : null, strokeWidth: 4, - backgroundColor: colorScheme.surfaceContainerHighest, + trackColor: colorScheme.surfaceContainerHighest, ), Icon(widget.icon, color: colorScheme.primary, size: 24), ], @@ -168,9 +169,9 @@ class _BatchProgressDialogState extends State { const SizedBox(height: 12), ClipRRect( borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( + child: AppProgressIndicator.linear( value: progress > 0 ? progress : null, - backgroundColor: colorScheme.surfaceContainerHighest, + trackColor: colorScheme.surfaceContainerHighest, minHeight: 6, ), ), diff --git a/lib/widgets/error_card.dart b/lib/widgets/error_card.dart index f5405698..d88cc15c 100644 --- a/lib/widgets/error_card.dart +++ b/lib/widgets/error_card.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_icon.dart'; +import 'package:spotiflac_android/widgets/app_shape.dart'; import 'package:spotiflac_android/widgets/expressive_button.dart'; /// Error card shown by detail screens: a dedicated rate-limit presentation @@ -124,7 +127,19 @@ class EmptyState extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 64, color: colorScheme.onSurfaceVariant), + if (context.isMornye) + Icon(icon, size: 64, color: colorScheme.onSurfaceVariant) + else + AppShapeBadge( + kind: AppShapes.badge, + size: 112, + color: colorScheme.secondaryContainer, + child: AppIcon( + icon, + size: 52, + color: colorScheme.onSecondaryContainer, + ), + ), SizedBox(height: tokens.gapLg), Text( title, diff --git a/lib/widgets/expressive_playback_transport.dart b/lib/widgets/expressive_playback_transport.dart new file mode 100644 index 00000000..1ead9724 --- /dev/null +++ b/lib/widgets/expressive_playback_transport.dart @@ -0,0 +1,237 @@ +import 'dart:math' as math; + +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/app_loading_indicator.dart'; + +/// Material 3 Expressive previous / play-pause / next row. +/// +/// Play-pause rests round while paused and square while playing. Pressing a +/// button widens it and squishes its neighbours, keeping the row width fixed. +/// Callers use the standard transport row when expressive motion is off. +class ExpressivePlaybackTransport extends StatefulWidget { + const ExpressivePlaybackTransport({ + super.key, + required this.colorScheme, + required this.playing, + required this.loading, + required this.playTooltip, + required this.previousTooltip, + required this.nextTooltip, + required this.onPlayPause, + required this.onPrevious, + required this.onNext, + this.leading, + this.trailing, + this.compact = false, + }); + + final ColorScheme colorScheme; + final bool playing; + final bool loading; + final String playTooltip; + final String previousTooltip; + final String nextTooltip; + final VoidCallback onPlayPause; + final VoidCallback onPrevious; + final VoidCallback onNext; + + /// 48dp actions beside the group, such as shuffle and repeat. + final Widget? leading; + final Widget? trailing; + final bool compact; + + @override + State createState() => + _ExpressivePlaybackTransportState(); +} + +class _ExpressivePlaybackTransportState + extends State { + static const _gap = 8.0; + static const _sideActionWidth = 48.0; + static const _minTarget = 48.0; + static const _squishRatio = 0.15; + + int? _pressed; + + void _press(int? index) { + if (_pressed != index) setState(() => _pressed = index); + } + + @override + Widget build(BuildContext context) { + final compact = widget.compact; + final height = compact ? 56.0 : 72.0; + final restingWidths = compact ? [56.0, 80.0, 56.0] : [68.0, 104.0, 68.0]; + return LayoutBuilder( + builder: (context, constraints) { + final sideActions = + (widget.leading == null ? 0 : 1) + + (widget.trailing == null ? 0 : 1); + final groupWidth = restingWidths.reduce((a, b) => a + b); + final available = + constraints.maxWidth - + sideActions * (_sideActionWidth + _gap) - + 2 * _gap; + var widths = [...restingWidths]; + if (constraints.hasBoundedWidth && available < groupWidth) { + // Narrow phones: previous/next keep a 48dp target and play-pause + // absorbs the remaining shortfall. + final side = math.max( + _minTarget, + restingWidths.first * available / groupWidth, + ); + widths = [side, math.max(_minTarget, available - 2 * side), side]; + } + final pressed = _pressed; + if (pressed != null) { + // Neighbours give up exactly what the pressed button gains. + final growth = widths[pressed] * _squishRatio; + final neighbours = [ + pressed - 1, + pressed + 1, + ].where((index) => index >= 0 && index < widths.length).toList(); + widths[pressed] += growth; + for (final index in neighbours) { + widths[index] -= growth / neighbours.length; + } + } + final scheme = widget.colorScheme; + final playIcon = widget.loading + ? AppLoadingIndicator(color: scheme.onSurfaceVariant) + : null; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.leading case final leading?) ...[ + leading, + const SizedBox(width: _gap), + ], + _slot( + index: 0, + width: widths[0], + height: height, + child: _button( + tooltip: widget.previousTooltip, + icon: const AppIcon(Icons.skip_previous), + iconSize: compact ? 28 : 32, + round: false, + background: scheme.secondaryContainer, + foreground: scheme.onSecondaryContainer, + onPressed: widget.onPrevious, + ), + ), + const SizedBox(width: _gap), + _slot( + index: 1, + width: widths[1], + height: height, + child: _button( + tooltip: widget.playTooltip, + icon: playIcon ?? const AppIcon(Icons.play_arrow), + selectedIcon: playIcon ?? const AppIcon(Icons.pause), + selected: widget.playing, + iconSize: compact ? 32 : 40, + round: true, + background: scheme.primary, + foreground: scheme.onPrimary, + onPressed: widget.loading ? null : widget.onPlayPause, + ), + ), + const SizedBox(width: _gap), + _slot( + index: 2, + width: widths[2], + height: height, + child: _button( + tooltip: widget.nextTooltip, + icon: const AppIcon(Icons.skip_next), + iconSize: compact ? 28 : 32, + round: false, + background: scheme.secondaryContainer, + foreground: scheme.onSecondaryContainer, + onPressed: widget.onNext, + ), + ), + if (widget.trailing case final trailing?) ...[ + const SizedBox(width: _gap), + trailing, + ], + ], + ); + }, + ); + } + + Widget _slot({ + required int index, + required double width, + required double height, + required Widget child, + }) => Listener( + onPointerDown: (_) => _press(index), + onPointerUp: (_) => _press(null), + onPointerCancel: (_) => _press(null), + // One curve for every slot keeps the summed width constant mid-flight. + child: TweenAnimationBuilder( + tween: Tween(end: width), + duration: const Duration(milliseconds: 350), + curve: Curves.easeOutBack, + builder: (context, width, child) => + SizedBox(width: math.max(0, width), height: height, child: child), + child: child, + ), + ); + + Widget _button({ + required String tooltip, + required Widget icon, + Widget? selectedIcon, + bool? selected, + required double iconSize, + required bool round, + required Color background, + required Color foreground, + required VoidCallback? onPressed, + }) { + final scheme = Theme.of(context).colorScheme; + Widget sized(Widget icon) => IconTheme.merge( + data: IconThemeData(size: iconSize), + child: icon, + ); + return Tooltip( + message: tooltip, + child: MaterialExpressiveScope( + child: M3EIconButton( + icon: sized(icon), + selectedIcon: selectedIcon == null ? null : sized(selectedIcon), + isSelected: selected, + semanticLabel: tooltip, + onPressed: onPressed, + size: widget.compact ? M3EIconButtonSize.md : M3EIconButtonSize.lg, + shape: round + ? M3EIconButtonShapeVariant.round + : M3EIconButtonShapeVariant.square, + variant: M3EIconButtonVariant.filled, + matchParentConstraints: true, + inflateHitTarget: false, + decoration: M3EIconButtonDecoration( + foregroundColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.disabled) + ? scheme.onSurface.withValues(alpha: 0.38) + : foreground, + ), + backgroundColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.disabled) + ? scheme.onSurface.withValues(alpha: 0.12) + : background, + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/material_player_layout.dart b/lib/widgets/material_player_layout.dart index 0e048716..f7f5454b 100644 --- a/lib/widgets/material_player_layout.dart +++ b/lib/widgets/material_player_layout.dart @@ -41,8 +41,31 @@ class MaterialPlayerLayout extends StatelessWidget { child: PlayerArtworkDragRegion( child: Hero( tag: kNowPlayingArtworkHeroTag, - child: ClipRRect( - borderRadius: BorderRadius.circular(20), + // Expressive rest: paused artwork settles smaller and rounder. + child: Consumer( + builder: (context, ref, artwork) { + final resting = + !ref.watch(playbackPlayingProvider) && + !ref.watch(playbackLoadingProvider); + final duration = MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 500); + return AnimatedScale( + scale: resting ? 0.9 : 1, + duration: duration, + curve: Curves.easeOutBack, + child: TweenAnimationBuilder( + tween: Tween(end: resting ? 40 : 20), + duration: duration, + curve: Curves.easeOutCubic, + builder: (context, radius, artwork) => ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: artwork, + ), + child: artwork, + ), + ); + }, child: SizedBox( width: artSize, height: artSize, diff --git a/lib/widgets/player_playback_controls.dart b/lib/widgets/player_playback_controls.dart index 322dbd51..c5d32b3d 100644 --- a/lib/widgets/player_playback_controls.dart +++ b/lib/widgets/player_playback_controls.dart @@ -6,6 +6,7 @@ import 'package:spotiflac_android/controllers/player_metadata_controller.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/theme/material_expressive.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/utils/playback_seek_preview.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; @@ -13,6 +14,7 @@ import 'package:spotiflac_android/widgets/app_icon.dart'; import 'package:spotiflac_android/widgets/app_loading_indicator.dart'; import 'package:spotiflac_android/widgets/automix_status_badge.dart'; import 'package:spotiflac_android/widgets/expressive_icon_button.dart'; +import 'package:spotiflac_android/widgets/expressive_playback_transport.dart'; import 'package:spotiflac_android/widgets/mornye_playback_button.dart'; import 'package:spotiflac_android/widgets/mornye_playback_time.dart'; import 'package:spotiflac_android/widgets/mornye_player_slider.dart'; @@ -146,89 +148,118 @@ class PlayerPlaybackControls extends ConsumerWidget { ), ), SizedBox(height: compact ? 8 : transportTopPadding + (mornye ? 0 : 8)), - Padding( - padding: EdgeInsets.symmetric( - horizontal: mornye ? (compact ? 16 : 32) : 0, + if (!mornye && materialExpressiveEnabled(context)) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ExpressivePlaybackTransport( + colorScheme: colorScheme, + playing: isPlaying, + loading: isLoading, + compact: compact, + playTooltip: isPlaying + ? context.l10n.actionPause + : context.l10n.tooltipPlay, + previousTooltip: context.l10n.nowPlayingPreviousTrack, + nextTooltip: context.l10n.nowPlayingNextTrack, + onPlayPause: () => controller.togglePlayPause(isPlaying), + onPrevious: controller.previous, + onNext: controller.next, + leading: PlayerPlaybackModeButton( + mode: PlayerPlaybackMode.shuffle, + colorScheme: colorScheme, + ), + trailing: PlayerPlaybackModeButton( + mode: PlayerPlaybackMode.repeat, + colorScheme: colorScheme, + ), + ), + ) + else + Padding( + padding: EdgeInsets.symmetric( + horizontal: mornye ? (compact ? 16 : 32) : 0, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + if (!mornye) + PlayerPlaybackModeButton( + mode: PlayerPlaybackMode.shuffle, + colorScheme: colorScheme, + ), + if (mornye) + MornyePlaybackButton( + icon: CupertinoIcons.backward_fill, + color: colorScheme.onSurface, + tooltip: context.l10n.nowPlayingPreviousTrack, + onPressed: controller.previous, + ) + else + ExpressiveIconButton( + iconSize: 44, + foregroundColor: colorScheme.onSurface, + tooltip: context.l10n.nowPlayingPreviousTrack, + icon: const Icon(Icons.skip_previous), + onPressed: controller.previous, + ), + if (mornye) + MornyePlaybackButton( + icon: isPlaying + ? CupertinoIcons.pause_fill + : CupertinoIcons.play_fill, + tooltip: isPlaying + ? context.l10n.actionPause + : context.l10n.tooltipPlay, + color: colorScheme.onSurface, + iconSize: playPauseSize, + // A smaller play glyph keeps the same 68dp touch target and + // does not move the timeline, artwork or adjacent buttons. + padding: EdgeInsets.all((68 - playPauseSize) / 2), + loading: isLoading, + onPressed: () => controller.togglePlayPause(isPlaying), + ) + else + ExpressiveIconButton( + size: 68, + iconSize: 44, + selected: isPlaying, + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + tooltip: isPlaying + ? context.l10n.actionPause + : context.l10n.tooltipPlay, + icon: isLoading + ? AppLoadingIndicator( + color: colorScheme.onSurfaceVariant, + ) + : Icon(isPlaying ? Icons.pause : Icons.play_arrow), + onPressed: isLoading + ? null + : () => controller.togglePlayPause(isPlaying), + ), + if (mornye) + MornyePlaybackButton( + icon: CupertinoIcons.forward_fill, + color: colorScheme.onSurface, + tooltip: context.l10n.nowPlayingNextTrack, + onPressed: controller.next, + ) + else + ExpressiveIconButton( + iconSize: 44, + foregroundColor: colorScheme.onSurface, + tooltip: context.l10n.nowPlayingNextTrack, + icon: const Icon(Icons.skip_next), + onPressed: controller.next, + ), + if (!mornye) + PlayerPlaybackModeButton( + mode: PlayerPlaybackMode.repeat, + colorScheme: colorScheme, + ), + ], + ), ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - if (!mornye) - PlayerPlaybackModeButton( - mode: PlayerPlaybackMode.shuffle, - colorScheme: colorScheme, - ), - if (mornye) - MornyePlaybackButton( - icon: CupertinoIcons.backward_fill, - color: colorScheme.onSurface, - tooltip: context.l10n.nowPlayingPreviousTrack, - onPressed: controller.previous, - ) - else - ExpressiveIconButton( - iconSize: 44, - foregroundColor: colorScheme.onSurface, - tooltip: context.l10n.nowPlayingPreviousTrack, - icon: const Icon(Icons.skip_previous), - onPressed: controller.previous, - ), - if (mornye) - MornyePlaybackButton( - icon: isPlaying - ? CupertinoIcons.pause_fill - : CupertinoIcons.play_fill, - tooltip: isPlaying - ? context.l10n.actionPause - : context.l10n.tooltipPlay, - color: colorScheme.onSurface, - iconSize: playPauseSize, - // A smaller play glyph keeps the same 68dp touch target and - // does not move the timeline, artwork or adjacent buttons. - padding: EdgeInsets.all((68 - playPauseSize) / 2), - loading: isLoading, - onPressed: () => controller.togglePlayPause(isPlaying), - ) - else - ExpressiveIconButton( - size: 68, - iconSize: 44, - selected: isPlaying, - backgroundColor: colorScheme.primary, - foregroundColor: colorScheme.onPrimary, - tooltip: isPlaying - ? context.l10n.actionPause - : context.l10n.tooltipPlay, - icon: isLoading - ? AppLoadingIndicator(color: colorScheme.onSurfaceVariant) - : Icon(isPlaying ? Icons.pause : Icons.play_arrow), - onPressed: isLoading - ? null - : () => controller.togglePlayPause(isPlaying), - ), - if (mornye) - MornyePlaybackButton( - icon: CupertinoIcons.forward_fill, - color: colorScheme.onSurface, - tooltip: context.l10n.nowPlayingNextTrack, - onPressed: controller.next, - ) - else - ExpressiveIconButton( - iconSize: 44, - foregroundColor: colorScheme.onSurface, - tooltip: context.l10n.nowPlayingNextTrack, - icon: const Icon(Icons.skip_next), - onPressed: controller.next, - ), - if (!mornye) - PlayerPlaybackModeButton( - mode: PlayerPlaybackMode.repeat, - colorScheme: colorScheme, - ), - ], - ), - ), ], ); } diff --git a/lib/widgets/selection_bottom_bar.dart b/lib/widgets/selection_bottom_bar.dart index a20dc948..48cc8c39 100644 --- a/lib/widgets/selection_bottom_bar.dart +++ b/lib/widgets/selection_bottom_bar.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/scheduler.dart'; @@ -332,30 +334,56 @@ class SelectionBottomBar extends StatelessWidget { ], ), ); + const floatingMargin = 12.0; return _SelectionPanelDrag( onClose: onClose, header: header, bottomPadding: bottomPadding, + floatingMargin: context.isMornye ? 0 : floatingMargin, children: children, surfaceBuilder: (content) { if (context.isMornye) { return MornyeGlassPanel.overlay(child: content); } - return Container( - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.vertical( - top: Radius.circular(tokens.radiusSheet), - ), - boxShadow: [ - BoxShadow( - color: colorScheme.shadow.withValues(alpha: 0.15), - blurRadius: 12, - offset: const Offset(0, -4), + // Expressive floating toolbar: detached from the screen edges and + // fully rounded. The faded backdrop hides and blocks the navigation + // bar underneath, so a tap beside the toolbar cannot switch tabs. + final surface = colorScheme.surface; + return Listener( + behavior: HitTestBehavior.opaque, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [surface.withValues(alpha: 0), surface], + stops: const [0, 0.4], ), - ], + ), + child: Padding( + padding: EdgeInsets.fromLTRB( + floatingMargin, + 0, + floatingMargin, + floatingMargin + MediaQuery.viewPaddingOf(context).bottom, + ), + child: Material( + color: colorScheme.surfaceContainerHigh, + surfaceTintColor: Colors.transparent, + shadowColor: colorScheme.shadow, + elevation: 3, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(tokens.radiusSheet), + ), + clipBehavior: Clip.antiAlias, + child: MediaQuery.removePadding( + context: context, + removeBottom: true, + child: content, + ), + ), + ), ), - child: content, ); }, ); @@ -372,11 +400,16 @@ class _SelectionPanelDrag extends StatefulWidget { required this.bottomPadding, required this.children, required this.surfaceBuilder, + this.floatingMargin = 0, }); final VoidCallback onClose; final Widget header; final double bottomPadding; + + /// Bottom margin the surface already places below a floating panel. The + /// content gives up the same space so the panel keeps its docked height. + final double floatingMargin; final List children; final Widget Function(Widget) surfaceBuilder; @@ -522,7 +555,10 @@ class _SelectionPanelDragState extends State<_SelectionPanelDrag> 16, 16, 16, - widget.bottomPadding > 0 ? 8 : 16, + math.max( + 0, + (widget.bottomPadding > 0 ? 8 : 16) - widget.floatingMargin, + ), ), child: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/widgets/update_dialog.dart b/lib/widgets/update_dialog.dart index 46eab9e5..27385d53 100644 --- a/lib/widgets/update_dialog.dart +++ b/lib/widgets/update_dialog.dart @@ -9,6 +9,7 @@ import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:spotiflac_android/widgets/app_action_button.dart'; import 'package:spotiflac_android/widgets/app_loading_indicator.dart'; +import 'package:spotiflac_android/widgets/app_progress_indicator.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; class UpdateDialog extends StatefulWidget { @@ -302,11 +303,10 @@ class _UpdateDialogState extends State { const SizedBox(height: 12), ClipRRect( borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( + child: AppProgressIndicator.linear( value: _progress, minHeight: 6, - backgroundColor: - colorScheme.surfaceContainerHighest, + trackColor: colorScheme.surfaceContainerHighest, ), ), const SizedBox(height: 8), diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index 5eb98b0d..e1f53fd1 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -11,6 +11,8 @@ import 'package:flutter/rendering.dart' import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_3_expressive/material_3_expressive.dart' + show M3EIconButton; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/controllers/player_metadata_controller.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; @@ -28,6 +30,7 @@ import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/theme/app_theme.dart'; import 'package:spotiflac_android/utils/lyrics_parser.dart'; import 'package:spotiflac_android/widgets/expressive_icon_button.dart'; +import 'package:spotiflac_android/widgets/expressive_playback_transport.dart'; import 'package:spotiflac_android/widgets/expressive_seek_track.dart'; import 'package:spotiflac_android/widgets/mornye_volume_control.dart'; import 'package:spotiflac_android/widgets/lyric_gap_indicator.dart'; @@ -1978,8 +1981,19 @@ void main() { ); mediaItems.add(item('first')); await settlePlayer(tester); - expect(find.byType(ExpressiveIconButton), findsNWidgets(6)); - for (final element in find.byType(ExpressiveIconButton).evaluate()) { + // Shuffle, previous, play-pause, next and repeat form the expressive + // transport row; the lyrics toggle sits outside it. + final transport = find.descendant( + of: find.byType(ExpressivePlaybackTransport), + matching: find.byType(M3EIconButton), + ); + final lyricsToggle = find.byKey(const ValueKey('material-lyrics-toggle')); + expect(transport, findsNWidgets(5)); + expect(lyricsToggle, findsOneWidget); + for (final element in [ + ...transport.evaluate(), + ...lyricsToggle.evaluate(), + ]) { final bounds = tester.getRect(find.byWidget(element.widget)); expect(bounds.left, greaterThanOrEqualTo(0)); expect(bounds.right, lessThanOrEqualTo(width));