feat(ui): extend Material Expressive surfaces and playback controls

This commit is contained in:
zarzet committed 2026-10-13 22:58:00 +07:00
1 parent c0fd506c78
commit 62ce404390
31 files changed
+880 -205

No files matched your search

+3 -1
View File
@@ -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<ArtistConcertsScreen> {
sliver: SliverToBoxAdapter(
child: Row(
children: [
ClipOval(
AppShapeClip(
kind: AppShapes.artist,
child: SizedBox.square(
dimension: 96,
child: widget.coverUrl == null || widget.coverUrl!.isEmpty
+1
View File
@@ -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';
+4 -4
View File
@@ -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,
),
),
+3 -1
View File
@@ -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<ConcertDetailScreen> {
Stack(
clipBehavior: Clip.none,
children: [
ClipOval(
AppShapeClip(
kind: AppShapes.artist,
child: SizedBox.square(
dimension: photoSize,
child: source == null
+3 -1
View File
@@ -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,
+84 -17
View File
@@ -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<HomeTab>
},
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<HomeTab>
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<HomeTab>
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(
+5 -8
View File
@@ -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)
+20 -19
View File
@@ -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(
+5 -2
View File
@@ -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
+4 -2
View File
@@ -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<NetworkStorageScreen> {
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(),
),
],
),
+1
View File
@@ -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';
+4 -6
View File
@@ -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),
),
),
),
+5 -5
View File
@@ -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,
),
+2 -1
View File
@@ -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<RepoTab> {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
body: RefreshIndicator(
body: AppRefreshIndicator(
onRefresh: () =>
ref.read(repoProvider.notifier).refresh(forceRefresh: true),
child: CustomScrollView(
@@ -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),
),
+2 -1
View File
@@ -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<WeeklyReleasesScreen> {
Widget build(BuildContext context) {
final feed = ref.watch(weeklyReleasesProvider(_refresh));
return Scaffold(
body: RefreshIndicator(
body: AppRefreshIndicator(
onRefresh: _reload,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
+3
View File
@@ -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,
),
+3 -2
View File
@@ -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
+16 -13
View File
@@ -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),
+100
View File
@@ -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,
),
);
},
);
}
}
+36
View File
@@ -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,
),
);
}
}
+70
View File
@@ -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),
),
),
);
}
+36 -10
View File
@@ -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,
);
},
),
);
+5 -4
View File
@@ -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<BatchProgressDialog> {
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<BatchProgressDialog> {
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,
),
),
+16 -1
View File
@@ -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,
@@ -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<ExpressivePlaybackTransport> createState() =>
_ExpressivePlaybackTransportState();
}
class _ExpressivePlaybackTransportState
extends State<ExpressivePlaybackTransport> {
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<double>(
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,
),
),
),
),
);
}
}
+25 -2
View File
@@ -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<double>(
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,
+113 -82
View File
@@ -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,
),
],
),
),
],
);
}
+50 -14
View File
@@ -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<Widget> 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,
+3 -3
View File
@@ -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<UpdateDialog> {
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),
+16 -2
View File
@@ -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));