feat(home): greet in the header and gather listening shortcuts

Without a feed, the Material Home now greets in its header with a
prompt picked once per launch, keeps import and paste out of the
resting search field, and fills the page with Continue listening,
player shortcuts, Recently added tracks from the whole Library and the
most played tracks of the last four weeks.
This commit is contained in:
zarzet committed 2026-10-18 18:08:13 +07:00
1 parent 96dc66f34f
commit 67a346c15b
6 files changed
+862 -45

No files matched your search

+28
View File
@@ -7342,6 +7342,34 @@
}
}
},
"homeTaglineListening": "What are we listening to?",
"@homeTaglineListening": {
"description": "Rotating Home prompt under the greeting"
},
"homeTaglineLink": "Got a link? Drop it in the search",
"@homeTaglineLink": {
"description": "Rotating Home prompt explaining that links can be pasted into search"
},
"homeTaglineDiscover": "Find your next favorite album",
"@homeTaglineDiscover": {
"description": "Rotating Home prompt under the greeting"
},
"homeContinueListening": "Continue listening",
"@homeContinueListening": {
"description": "Home section header for the track currently loaded in the player"
},
"homeRecentlyAdded": "Recently added",
"@homeRecentlyAdded": {
"description": "Home section header for the newest downloaded and scanned tracks"
},
"homeOnRepeat": "On repeat",
"@homeOnRepeat": {
"description": "Home section header for the most played tracks of the last four weeks"
},
"homeShortcuts": "Shortcuts",
"@homeShortcuts": {
"description": "Home section header for quick links to player tools"
},
"artistUpcomingConcerts": "Upcoming Concerts",
"artistAllUpcomingConcerts": "All Upcoming Concerts",
"artistNoUpcomingConcerts": "No upcoming concerts.",
+7
View File
@@ -7314,6 +7314,13 @@
"homeGreetingNight2": "Teman begadang",
"homeGreetingNight3": "Malam yang tenang",
"homeGreetingNamed": "{greeting}, {name}",
"homeTaglineListening": "Mau dengar apa hari ini?",
"homeTaglineLink": "Punya link? Tempel saja di pencarian",
"homeTaglineDiscover": "Temukan album favoritmu berikutnya",
"homeContinueListening": "Lanjutkan mendengarkan",
"homeRecentlyAdded": "Baru ditambahkan",
"homeOnRepeat": "Sering diputar",
"homeShortcuts": "Pintasan",
"artistUpcomingConcerts": "Konser Mendatang",
"artistAllUpcomingConcerts": "Semua Konser Mendatang",
"artistNoUpcomingConcerts": "Tiada konser mendatang.",
+46 -44
View File
@@ -43,6 +43,16 @@ import 'package:spotiflac_android/providers/explore_provider.dart';
import 'package:spotiflac_android/providers/user_profile_provider.dart';
import 'package:spotiflac_android/widgets/explore_featured_section.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/models/unified_library_item.dart';
import 'package:spotiflac_android/screens/equalizer_screen.dart';
import 'package:spotiflac_android/screens/listening_statistics_screen.dart';
import 'package:spotiflac_android/screens/network_storage_screen.dart';
import 'package:spotiflac_android/screens/now_playing_screen.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/listening_statistics.dart';
import 'package:spotiflac_android/services/music_player_service.dart'
show PlayableMedia;
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart';
@@ -68,6 +78,7 @@ import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
import 'package:spotiflac_android/widgets/player_artwork.dart';
import 'package:spotiflac_android/widgets/error_card.dart';
import 'package:spotiflac_android/widgets/in_library_badge.dart';
import 'package:spotiflac_android/widgets/preview_button.dart';
@@ -80,6 +91,7 @@ part 'home_tab_helpers.dart';
part 'home_tab_explore.dart';
part 'home_tab_recent.dart';
part 'home_tab_import.dart';
part 'home_tab_intro.dart';
part 'home_tab_search_results.dart';
part 'home_tab_widgets.dart';
@@ -890,6 +902,23 @@ class _HomeTabState extends ConsumerState<HomeTab>
context,
const AppMotionSpec(Duration(milliseconds: 250), Curves.easeOut),
);
// Without a feed, the Material Home greets in its header and gathers
// the player and shortcuts under the search bar. A feed brings its own
// greeting, and the Mornye style keeps its navigation title.
final greetsInHeader =
_showsHome &&
widget.mode != HomeTabMode.search &&
!context.isMornye &&
!showExplore &&
!showEmptyHomeState;
final greeting = greetsInHeader
? homeGreeting(
context.l10n,
name: ref.watch(
userProfileProvider.select((p) => p.value?.name ?? ''),
),
)
: null;
ref.listen<String>(settingsProvider.select((s) => s.defaultSearchTab), (
previous,
@@ -944,7 +973,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
alignToContent: true,
title: widget.mode == HomeTabMode.search
? context.l10n.mornyeSearch
: context.l10n.homeTitle,
: greeting ?? context.l10n.homeTitle,
actions: context.isMornye && _showsHome
? const [HomeProfileButton()]
: null,
@@ -970,7 +999,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
// Centered with the search results on tablets.
padding: EdgeInsets.fromLTRB(
16 + wideListInset(context),
(hasResults || showExplore) ? 8 : 32,
(hasResults || showExplore)
? 8
: greetsInHeader
? 20
: 32,
16 + wideListInset(context),
(hasResults || showExplore) ? 8 : 16,
),
@@ -1038,7 +1071,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
? const SizedBox.shrink()
: Column(
children: [
if (_showsSearch && !hasSearchedBefore)
if (greetsInHeader)
_HomeSections(inset: 16 + wideListInset(context)),
if (_showsSearch &&
!hasSearchedBefore &&
!greetsInHeader)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
@@ -1050,7 +1087,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
),
if (hasHistoryItems)
if (hasHistoryItems && !greetsInHeader)
Consumer(
builder: (context, ref, _) {
final historyItems = ref.watch(
@@ -1229,47 +1266,12 @@ class _HomeTabState extends ConsumerState<HomeTab>
// in the content area.
if (context.isMornye) return const SizedBox.shrink();
// The header carries the greeting; prompts take turns beneath it.
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
SizedBox(height: screenHeight * 0.06),
AppShapeBadge(
kind: AppShapes.brand,
size: 96,
color: colorScheme.primary,
child: Image.asset(
'assets/images/logo-transparent.png',
color: colorScheme.onPrimary,
fit: BoxFit.contain,
errorBuilder: (_, _, _) => ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Image.asset(
'assets/images/logo.png',
width: 96,
height: 96,
fit: BoxFit.cover,
),
),
),
),
const SizedBox(height: 16),
Text(
'SpotiFLAC Mobile',
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
context.l10n.homeSubtitle,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
padding: EdgeInsets.symmetric(horizontal: 24 + wideListInset(context)),
child: const Align(
alignment: AlignmentDirectional.centerStart,
child: _HomeTagline(),
),
);
}
+762
View File
@@ -0,0 +1,762 @@
part of 'home_tab.dart';
const _homeShelfLimit = 10;
const _homeShelfCoverSize = 120.0;
/// The newest downloaded and scanned tracks, with the Library's track count
/// for the shortcut at the end of the shelf.
final _homeRecentlyAddedProvider =
FutureProvider.autoDispose<
({List<UnifiedLibraryItem> items, int trackCount})
>((ref) async {
final includeLocal = ref.watch(
settingsProvider.select((s) => s.localLibraryEnabled),
);
// Reload when a download lands or a scan rebuilds the index.
ref.watch(downloadHistoryProvider.select((s) => s.items.length));
ref.watch(localLibraryProvider.select((s) => s.loadedIndexVersion));
final database = LibraryDatabase.instance;
final rows = await database.getQueueTrackPage(
QueueLibraryDbQuery(includeLocal: includeLocal, limit: _homeShelfLimit),
);
final counts = await database.getQueueCounts(
QueueLibraryDbQuery(includeLocal: includeLocal),
);
final items = <UnifiedLibraryItem>[];
for (final row in rows) {
final json = Map<String, dynamic>.from(row['item'] as Map);
switch (row['source']) {
case 'local':
items.add(
UnifiedLibraryItem.fromLocalLibrary(
LocalLibraryItem.fromJson(json),
),
);
case 'downloaded':
items.add(
UnifiedLibraryItem.fromDownloadHistory(
DownloadHistoryItem.fromJson(json),
),
);
}
}
return (items: items, trackCount: counts.allTrackCount);
});
/// The most played files of the last four weeks.
final _homeOnRepeatProvider = FutureProvider.autoDispose<List<ListeningTrack>>((
ref,
) async {
// A new track means the previous one may have just earned a play.
ref.watch(currentMediaItemProvider.select((s) => s.value?.id));
await listeningRecorder.flush();
final now = DateTime.now();
final summary = await ListeningStatisticsStore.instance.load(
DateTime(now.year, now.month, now.day - 27),
DateTime(now.year, now.month, now.day + 1),
);
return [
for (final total in summary.tracks)
// Remote sources cannot be replayed from their recorded key.
if (total.plays > 0 &&
!total.track.key.startsWith('http://') &&
!total.track.key.startsWith('https://'))
total.track,
].take(_homeShelfLimit).toList(growable: false);
});
/// The prompt under the Home greeting, picked again on each launch.
class _HomeTagline extends StatelessWidget {
const _HomeTagline();
@override
Widget build(BuildContext context) => Text(
homeTagline(context.l10n),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
}
/// Everything under the search bar on the Material Home. Sections without
/// content leave no gap behind.
class _HomeSections extends ConsumerWidget {
const _HomeSections({required this.inset});
/// Horizontal page margin, wider on tablets.
final double inset;
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = context.l10n;
final hasMediaItem = ref.watch(
currentMediaItemProvider.select((s) => s.value != null),
);
final recentlyAdded = ref.watch(_homeRecentlyAddedProvider).value;
final onRepeat = ref.watch(_homeOnRepeatProvider).value ?? const [];
final sections = <Widget>[
if (hasMediaItem)
_HomeSection(
title: l10n.homeContinueListening,
inset: inset,
child: const _HomeContinueListeningCard(),
),
_HomeSection(
title: l10n.homeShortcuts,
inset: inset,
child: const _HomeShortcuts(),
),
if (recentlyAdded != null && recentlyAdded.items.isNotEmpty)
_HomeSection(
title: l10n.homeRecentlyAdded,
inset: inset,
shelf: true,
child: _HomeRecentlyAddedShelf(
items: recentlyAdded.items,
trackCount: recentlyAdded.trackCount,
inset: inset,
),
),
if (onRepeat.isNotEmpty)
_HomeSection(
title: l10n.homeOnRepeat,
inset: inset,
shelf: true,
child: _HomeOnRepeatShelf(tracks: onRepeat, inset: inset),
),
];
return Padding(
padding: const EdgeInsets.only(top: 8, bottom: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (index, section) in sections.indexed) ...[
if (index > 0) const SizedBox(height: 28),
section,
],
],
),
);
}
}
class _HomeSection extends StatelessWidget {
const _HomeSection({
required this.title,
required this.inset,
required this.child,
this.shelf = false,
});
final String title;
final double inset;
final Widget child;
/// Shelves scroll edge to edge and pad their own items.
final bool shelf;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: EdgeInsets.fromLTRB(inset + 8, 0, inset + 8, 12),
child: Semantics(
header: true,
child: Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
),
),
),
if (shelf)
child
else
Padding(
padding: EdgeInsets.symmetric(horizontal: inset),
child: child,
),
],
);
}
}
/// The track loaded in the player, with its progress and a play-pause
/// button.
class _HomeContinueListeningCard extends ConsumerWidget {
const _HomeContinueListeningCard();
static const _coverSize = 96.0;
@override
Widget build(BuildContext context, WidgetRef ref) {
final mediaItem = ref.watch(currentMediaItemProvider).value;
if (mediaItem == null) return const SizedBox.shrink();
final isPlaying = ref.watch(playbackPlayingProvider);
final isLoading = ref.watch(playbackLoadingProvider);
final controller = ref.read(musicPlayerControllerProvider);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final artist = mediaItem.artist ?? '';
return Material(
color: colorScheme.primaryContainer,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => Navigator.of(
context,
rootNavigator: true,
).push(NowPlayingRoute(child: const NowPlayingScreen())),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 16, 12),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(20),
child: SizedBox.square(
dimension: _coverSize,
child: PlayerArtwork(
artUri: mediaItem.artUri?.toString(),
colorScheme: colorScheme,
cacheWidth:
(_coverSize * MediaQuery.devicePixelRatioOf(context))
.round(),
iconSize: 32,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
mediaItem.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onPrimaryContainer,
),
),
if (artist.isNotEmpty)
Text(
artist,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onPrimaryContainer.withValues(
alpha: 0.8,
),
),
),
const SizedBox(height: 12),
_HomeContinueListeningProgress(
duration: mediaItem.duration ?? Duration.zero,
),
],
),
),
const SizedBox(width: 12),
Tooltip(
message: isPlaying
? context.l10n.actionPause
: context.l10n.tooltipPlay,
child: Semantics(
button: true,
child: Material(
type: MaterialType.transparency,
child: InkWell(
customBorder: const CircleBorder(),
onTap: isLoading
? null
: () => controller.togglePlayPause(isPlaying),
child: AppShapeBadge(
kind: M3EShapeKind.sunny,
size: 56,
color: colorScheme.primary,
child: isLoading
? AppLoadingIndicator(
size: 24,
color: colorScheme.onPrimary,
)
: Icon(
isPlaying ? Icons.pause : Icons.play_arrow,
size: 28,
color: colorScheme.onPrimary,
),
),
),
),
),
),
],
),
),
),
);
}
}
/// Follows position ticks on its own so the card above does not rebuild on
/// every one.
class _HomeContinueListeningProgress extends ConsumerWidget {
const _HomeContinueListeningProgress({required this.duration});
final Duration duration;
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final durationMs = duration.inMilliseconds;
final positionMs = ref.watch(playbackPositionProvider).inMilliseconds;
final progress = durationMs > 0
? (positionMs / durationMs).clamp(0.0, 1.0)
: 0.0;
return ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: progress,
minHeight: 4,
color: colorScheme.primary,
backgroundColor: colorScheme.onPrimaryContainer.withValues(alpha: 0.16),
),
);
}
}
/// A horizontal row of cover tiles that scrolls past the page margin.
class _HomeShelf extends StatelessWidget {
const _HomeShelf({
required this.inset,
required this.itemCount,
required this.itemBuilder,
});
final double inset;
final int itemCount;
final IndexedWidgetBuilder itemBuilder;
@override
Widget build(BuildContext context) {
final textScale = MediaQuery.textScalerOf(context).scale(1);
return SizedBox(
height: _homeShelfCoverSize + 52 * textScale,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(horizontal: inset),
itemCount: itemCount,
separatorBuilder: (_, _) => const SizedBox(width: 12),
itemBuilder: itemBuilder,
),
);
}
}
class _HomeShelfTile extends StatelessWidget {
const _HomeShelfTile({
required this.cover,
required this.title,
required this.subtitle,
required this.semanticLabel,
required this.onTap,
});
final Widget cover;
final String title;
final String subtitle;
final String semanticLabel;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Semantics(
button: true,
label: semanticLabel,
excludeSemantics: true,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(20),
child: SizedBox(
width: _homeShelfCoverSize,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(20),
child: SizedBox.square(
dimension: _homeShelfCoverSize,
child: cover,
),
),
const SizedBox(height: 8),
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.labelLarge?.copyWith(
color: colorScheme.onSurface,
),
),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
);
}
}
class _HomeRecentlyAddedShelf extends ConsumerWidget {
const _HomeRecentlyAddedShelf({
required this.items,
required this.trackCount,
required this.inset,
});
final List<UnifiedLibraryItem> items;
final int trackCount;
final double inset;
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final cacheWidth =
(_homeShelfCoverSize * MediaQuery.devicePixelRatioOf(context)).round();
return _HomeShelf(
inset: inset,
itemCount: items.length + 1,
itemBuilder: (context, index) {
if (index == items.length) {
return _HomeLibraryTile(trackCount: trackCount);
}
final item = items[index];
final history = item.historyItem;
return _HomeShelfTile(
title: item.trackName,
subtitle: item.artistName,
semanticLabel: context.l10n.a11yOpenTrackByArtist(
item.trackName,
item.artistName,
),
cover: history != null
? _DownloadedOrRemoteCover(
downloadedFilePath: history.filePath,
imageUrl: history.coverUrl,
width: _homeShelfCoverSize,
height: _homeShelfCoverSize,
borderRadius: BorderRadius.zero,
fallbackIcon: Icons.music_note,
fallbackIconSize: 32,
colorScheme: colorScheme,
)
: PlayerArtwork(
artUri: item.localCoverPath,
colorScheme: colorScheme,
cacheWidth: cacheWidth,
iconSize: 32,
),
onTap: () => _play(ref, index),
);
},
);
}
void _play(WidgetRef ref, int index) {
final media = <PlayableMedia>[];
var initialIndex = 0;
for (final (i, item) in items.indexed) {
final playable = switch (item) {
UnifiedLibraryItem(:final historyItem?) => playableFromHistory(
historyItem,
),
UnifiedLibraryItem(:final localItem?) => playableFromLocal(localItem),
_ => null,
};
if (playable == null || playable.source.trim().isEmpty) continue;
if (i == index) initialIndex = media.length;
media.add(playable);
}
if (media.isEmpty) return;
ref
.read(musicPlayerControllerProvider)
.playAll(media, initialIndex: initialIndex);
}
}
/// Ends the Recently added shelf with a way into the whole Library.
class _HomeLibraryTile extends StatelessWidget {
const _HomeLibraryTile({required this.trackCount});
final int trackCount;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Align(
alignment: Alignment.topCenter,
child: SizedBox.square(
dimension: _homeShelfCoverSize,
child: Material(
color: colorScheme.tertiaryContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => ShellNavigationService.requestTab(ShellTab.library),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
AppShapeBadge(
kind: M3EShapeKind.clover4Leaf,
size: 40,
color: colorScheme.tertiary,
child: Icon(
Icons.library_music,
size: 20,
color: colorScheme.onTertiary,
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.navLibrary,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.labelLarge?.copyWith(
color: colorScheme.onTertiaryContainer,
),
),
Text(
context.l10n.tracksCount(trackCount),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onTertiaryContainer,
),
),
],
),
],
),
),
),
),
),
);
}
}
class _HomeOnRepeatShelf extends ConsumerWidget {
const _HomeOnRepeatShelf({required this.tracks, required this.inset});
final List<ListeningTrack> tracks;
final double inset;
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final cacheWidth =
(_homeShelfCoverSize * MediaQuery.devicePixelRatioOf(context)).round();
return _HomeShelf(
inset: inset,
itemCount: tracks.length,
itemBuilder: (context, index) {
final track = tracks[index];
return _HomeShelfTile(
title: track.title,
subtitle: track.artist,
semanticLabel: context.l10n.a11yOpenTrackByArtist(
track.title,
track.artist,
),
cover: PlayerArtwork(
artUri: track.artwork,
colorScheme: colorScheme,
cacheWidth: cacheWidth,
iconSize: 32,
),
onTap: () => ref.read(musicPlayerControllerProvider).playAll([
for (final track in tracks)
PlayableMedia(
id: track.key,
source: track.key,
title: track.title,
artist: track.artist,
album: track.album,
artUri: track.artwork,
),
], initialIndex: index),
);
},
);
}
}
/// Quick ways into player tools that otherwise sit a few screens deep.
class _HomeShortcuts extends ConsumerWidget {
const _HomeShortcuts();
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = context.l10n;
final colorScheme = Theme.of(context).colorScheme;
void open(Widget screen) => Navigator.of(
context,
).push(MaterialPageRoute<void>(builder: (_) => screen));
final tiles = [
_HomeShortcutTile(
label: l10n.nowPlayingShuffleLibrary,
icon: Icons.shuffle,
shape: M3EShapeKind.cookie9Sided,
container: colorScheme.primary,
onContainer: colorScheme.onPrimary,
badge: colorScheme.onPrimary,
onBadge: colorScheme.primary,
onTap: () => _shuffle(context, ref),
),
_HomeShortcutTile(
label: l10n.equalizerTitle,
icon: Icons.graphic_eq,
shape: M3EShapeKind.cookie4Sided,
container: colorScheme.tertiaryContainer,
onContainer: colorScheme.onTertiaryContainer,
badge: colorScheme.tertiary,
onBadge: colorScheme.onTertiary,
onTap: () => open(const EqualizerScreen()),
),
_HomeShortcutTile(
label: l10n.listeningStats,
icon: Icons.insights,
shape: M3EShapeKind.sunny,
container: colorScheme.secondaryContainer,
onContainer: colorScheme.onSecondaryContainer,
badge: colorScheme.secondary,
onBadge: colorScheme.onSecondary,
onTap: () => open(const ListeningStatisticsScreen()),
),
_HomeShortcutTile(
label: l10n.networkStorage,
icon: Icons.lan_outlined,
shape: M3EShapeKind.pentagon,
container: colorScheme.surfaceContainerHigh,
onContainer: colorScheme.onSurface,
badge: colorScheme.primaryContainer,
onBadge: colorScheme.onPrimaryContainer,
onTap: () => open(const NetworkStorageScreen()),
),
];
return Column(
children: [
for (var row = 0; row < tiles.length; row += 2) ...[
if (row > 0) const SizedBox(height: 8),
// A label that wraps keeps its neighbor the same height.
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: tiles[row]),
const SizedBox(width: 8),
Expanded(child: tiles[row + 1]),
],
),
),
],
],
);
}
Future<void> _shuffle(BuildContext context, WidgetRef ref) async {
final shuffled = await ref
.read(musicPlayerControllerProvider)
.shuffleLibrary();
if (shuffled || !context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.nowPlayingLibraryEmpty)),
);
}
}
class _HomeShortcutTile extends StatelessWidget {
const _HomeShortcutTile({
required this.label,
required this.icon,
required this.shape,
required this.container,
required this.onContainer,
required this.badge,
required this.onBadge,
required this.onTap,
});
final String label;
final IconData icon;
final M3EShapeKind shape;
final Color container;
final Color onContainer;
final Color badge;
final Color onBadge;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: container,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppShapeBadge(
kind: shape,
size: 40,
color: badge,
child: Icon(icon, size: 20, color: onBadge),
),
const SizedBox(height: 16),
Text(
label,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(color: onContainer),
),
],
),
),
),
);
}
}
+7 -1
View File
@@ -801,6 +801,12 @@ extension _HomeTabSearchResultsUI on _HomeTabState {
Widget _buildSearchBar() {
final hasText = _urlController.text.isNotEmpty;
// The Material Home keeps the resting field clean; import and paste
// appear once it is focused.
final showInputActions =
context.isMornye ||
widget.mode == HomeTabMode.search ||
_searchFocusNode.hasFocus;
return AppSearchField(
controller: _urlController,
@@ -830,7 +836,7 @@ extension _HomeTabSearchResultsUI on _HomeTabState {
onPressed: _clearAndRefresh,
tooltip: context.l10n.dialogClear,
)
else ...[
else if (showInputActions) ...[
AppIconButton(
icon: const Icon(Icons.file_upload_outlined),
onPressed: _isCsvImporting
+12
View File
@@ -43,3 +43,15 @@ String homeGreeting(
// A full name reads stiff in a headline.
return l10n.homeGreetingNamed(phrase, trimmed.split(RegExp(r'\s+')).first);
}
/// The prompt under the Home greeting. Like the greeting, it holds for the
/// whole launch.
String homeTagline(AppLocalizations l10n, {int? variant}) {
final options = [
l10n.homeTaglineListening,
l10n.homeTaglineLink,
l10n.homeTaglineDiscover,
];
// Offset from the greeting's pick so the two do not always pair up.
return options[((variant ?? _sessionVariant) ~/ 3) % options.length];
}