mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 15:02:21 +02:00
feat(ui): extend Material Expressive surfaces and playback controls
This commit is contained in:
1 parent
c0fd506c78
commit
62ce404390
31 files changed
+880
-205
No files matched your search
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,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),
|
||||
),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user