diff --git a/lib/app/view/home_page.dart b/lib/app/view/home_page.dart new file mode 100644 index 000000000..e8332e17e --- /dev/null +++ b/lib/app/view/home_page.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:watch_it/watch_it.dart'; + +import '../../common/data/audio_type.dart'; +import '../../common/view/adaptive_container.dart'; +import '../../common/view/header_bar.dart'; +import '../../common/view/icons.dart'; +import '../../common/view/search_button.dart'; +import '../../common/view/theme.dart'; +import '../../common/view/ui_constants.dart'; +import '../../constants.dart'; +import '../../extensions/build_context_x.dart'; +import '../../extensions/country_x.dart'; +import '../../l10n/l10n.dart'; +import '../../library/library_model.dart'; +import '../../local_audio/view/playlists_view.dart'; +import '../../search/search_model.dart'; +import '../../search/search_type.dart'; +import '../../search/view/sliver_podcast_search_results.dart'; +import '../../search/view/sliver_radio_country_grid.dart'; + +class HomePage extends StatelessWidget with WatchItMixin { + const HomePage({super.key}); + + @override + Widget build(BuildContext context) { + final textTheme = context.textTheme; + final l10n = context.l10n; + final playlists = watchPropertyValue( + (LibraryModel m) => m.playlists.keys.toList(), + ); + final style = textTheme.headlineSmall; + const textPadding = EdgeInsets.only( + left: kSmallestSpace, + bottom: kSmallestSpace, + ); + + final country = + watchPropertyValue((SearchModel m) => m.country?.localize(l10n)); + + return Scaffold( + appBar: HeaderBar( + title: Text(l10n.home), + adaptive: false, + actions: [ + const SearchButton(), + IconButton( + selectedIcon: Icon(Iconz.settingsFilled), + icon: Icon(Iconz.settings), + tooltip: l10n.settings, + onPressed: () => di().push(pageId: kSettingsPageId), + ), + const SizedBox( + width: kSmallestSpace, + ), + ], + ), + body: LayoutBuilder( + builder: (context, constraints) { + final padding = getAdaptiveHorizontalPadding( + constraints: constraints, + ); + return CustomScrollView( + slivers: [ + SliverPadding( + padding: padding, + sliver: SliverToBoxAdapter( + child: Padding( + padding: textPadding, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${l10n.podcast} ${l10n.charts} ${country ?? ''}', + style: style, + ), + IconButton( + onPressed: () { + di().push(pageId: kSearchPageId); + di() + ..setAudioType(AudioType.podcast) + ..setSearchType(SearchType.podcastTitle) + ..search(); + }, + icon: Icon(Iconz.goNext), + ), + ], + ), + ), + ), + ), + SliverPadding( + padding: padding, + sliver: const SliverPodcastSearchResults( + take: 3, + ), + ), + SliverPadding( + padding: padding, + sliver: SliverToBoxAdapter( + child: Padding( + padding: textPadding, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${l10n.radio} ${l10n.charts} ${country ?? ''}', + style: style, + ), + IconButton( + onPressed: () { + di().push(pageId: kSearchPageId); + di() + ..setAudioType(AudioType.radio) + ..setSearchType(SearchType.radioCountry) + ..search(); + }, + icon: Icon(Iconz.goNext), + ), + ], + ), + ), + ), + ), + SliverPadding( + padding: padding, + sliver: const SliverRadioCountryGrid(), + ), + SliverPadding( + padding: padding, + sliver: SliverToBoxAdapter( + child: Padding( + padding: textPadding, + child: Text( + '${l10n.playlists} ', + style: style, + ), + ), + ), + ), + SliverPadding( + padding: padding.copyWith( + bottom: bottomPlayerPageGap, + ), + sliver: PlaylistsView( + playlists: playlists, + take: 2, + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/app/view/master_items.dart b/lib/app/view/master_items.dart index f8963f325..16443deea 100644 --- a/lib/app/view/master_items.dart +++ b/lib/app/view/master_items.dart @@ -22,6 +22,7 @@ import '../../radio/view/station_page.dart'; import '../../radio/view/station_page_icon.dart'; import '../../search/view/search_page.dart'; import '../../settings/view/settings_page.dart'; +import 'home_page.dart'; import 'main_page_icon.dart'; class MasterItem { @@ -83,6 +84,14 @@ List createMasterItems({required LibraryModel libraryModel}) { pageBuilder: (context) => const SettingsPage(), pageId: kSettingsPageId, ), + if (isMobilePlatform) + MasterItem( + titleBuilder: (context) => Text(context.l10n.home), + iconBuilder: (selected) => + Icon(selected ? Iconz.homeFilled : Iconz.home), + pageBuilder: (context) => const HomePage(), + pageId: kHomePageId, + ), MasterItem( iconBuilder: (selected) => Icon(Iconz.plus), titleBuilder: (context) => Text(context.l10n.add), diff --git a/lib/app/view/mobile_navigation_bar.dart b/lib/app/view/mobile_navigation_bar.dart index 8f1b1a266..c1a30e407 100644 --- a/lib/app/view/mobile_navigation_bar.dart +++ b/lib/app/view/mobile_navigation_bar.dart @@ -27,12 +27,11 @@ class MobileNavigationBar extends StatelessWidget with WatchItMixin { mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ IconButton( - isSelected: selectedPageId == kLikedAudiosPageId, - selectedIcon: Icon(Iconz.heartFilled), - icon: Icon(Iconz.heart), - tooltip: l10n.local, - onPressed: () => - di().push(pageId: kLikedAudiosPageId), + isSelected: selectedPageId == kHomePageId, + selectedIcon: Icon(Iconz.homeFilled), + icon: Icon(Iconz.home), + tooltip: l10n.home, + onPressed: () => di().push(pageId: kHomePageId), ), IconButton( isSelected: selectedPageId == kLocalAudioPageId, @@ -74,13 +73,6 @@ class MobileNavigationBar extends StatelessWidget with WatchItMixin { tooltip: l10n.podcasts, onPressed: () => di().push(pageId: kPodcastsPageId), ), - IconButton( - isSelected: selectedPageId == kSettingsPageId, - selectedIcon: Icon(Iconz.settingsFilled), - icon: Icon(Iconz.settings), - tooltip: l10n.settings, - onPressed: () => di().push(pageId: kSettingsPageId), - ), ], ), ), diff --git a/lib/app/view/mobile_page.dart b/lib/app/view/mobile_page.dart index 776939568..38ef53a94 100644 --- a/lib/app/view/mobile_page.dart +++ b/lib/app/view/mobile_page.dart @@ -35,6 +35,7 @@ class MobilePage extends StatelessWidget with WatchItMixin { ); return Scaffold( + resizeToAvoidBottomInset: false, extendBody: true, extendBodyBehindAppBar: true, body: Stack( diff --git a/lib/common/view/icons.dart b/lib/common/view/icons.dart index 17f927eff..ad721a423 100644 --- a/lib/common/view/icons.dart +++ b/lib/common/view/icons.dart @@ -5,6 +5,17 @@ import 'package:yaru/yaru.dart'; import '../../app_config.dart'; class Iconz { + static IconData get home => yaruStyled + ? YaruIcons.home + : appleStyled + ? CupertinoIcons.home + : Icons.home_outlined; + static IconData get homeFilled => yaruStyled + ? YaruIcons.home_filled + : appleStyled + ? CupertinoIcons.home + : Icons.home_filled; + static IconData get image => yaruStyled ? YaruIcons.image : appleStyled diff --git a/lib/common/view/search_button.dart b/lib/common/view/search_button.dart index f3e9d25b2..6ff132178 100644 --- a/lib/common/view/search_button.dart +++ b/lib/common/view/search_button.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:watch_it/watch_it.dart'; import 'package:yaru/yaru.dart'; import '../../app_config.dart'; +import '../../constants.dart'; import '../../extensions/build_context_x.dart'; +import '../../library/library_model.dart'; import 'icons.dart'; import 'theme.dart'; @@ -15,16 +18,19 @@ class SearchButton extends StatelessWidget { @override Widget build(BuildContext context) { + final onTap = + onPressed ?? () => di().push(pageId: kSearchPageId); + return yaruStyled ? YaruSearchButton( searchActive: active, - onPressed: onPressed, + onPressed: onTap, icon: icon, selectedIcon: icon, ) : IconButton( isSelected: active, - onPressed: onPressed, + onPressed: onTap, selectedIcon: icon ?? Icon( Iconz.search, diff --git a/lib/constants.dart b/lib/constants.dart index f9d45b3e9..f708e1169 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -84,3 +84,4 @@ const kCloseBtnAction = 'closeBtnAction'; const kUseMoreAnimations = 'useMoreAnimations'; const kShowPositionDuration = 'showPositionDuration'; const kSettingsPageId = 'settings'; +const kHomePageId = 'homePage'; diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 224c15f22..81134f4f8 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1,5 +1,6 @@ { "@@locale": "de", + "home": "Home", "play": "Wiedergabe", "pause": "Pause", "stop": "Stop", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d9191d62f..5cac62078 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,5 +1,6 @@ { "@@locale": "en", + "home": "Home", "play": "Play", "pause": "Pause", "stop": "Stop", diff --git a/lib/library/library_service.dart b/lib/library/library_service.dart index 46ee41e5b..41ccf04cd 100644 --- a/lib/library/library_service.dart +++ b/lib/library/library_service.dart @@ -541,6 +541,7 @@ class LibraryService { isPodcastSubscribed(pageId)); final _mainPages = [ + kHomePageId, kSearchPageId, kLikedAudiosPageId, kLocalAudioPageId, diff --git a/lib/local_audio/view/playlists_view.dart b/lib/local_audio/view/playlists_view.dart index 705541a6e..2f633ef93 100644 --- a/lib/local_audio/view/playlists_view.dart +++ b/lib/local_audio/view/playlists_view.dart @@ -17,17 +17,19 @@ class PlaylistsView extends StatelessWidget { this.noResultMessage, this.noResultIcon, required this.playlists, + this.take, }); final List? playlists; final Widget? noResultMessage, noResultIcon; + final int? take; @override Widget build(BuildContext context) { final lists = [ kNewPlaylistPageId, kLikedAudiosPageId, - ...(playlists ?? []), + ...(take != null ? playlists!.take(take!).toList() : playlists ?? []), ]; return SliverGrid.builder( diff --git a/lib/playlists/view/manual_add_dialog.dart b/lib/playlists/view/manual_add_dialog.dart index a62f600b4..b244a5457 100644 --- a/lib/playlists/view/manual_add_dialog.dart +++ b/lib/playlists/view/manual_add_dialog.dart @@ -41,7 +41,7 @@ class ManualAddDialog extends StatelessWidget { bottom: kLargestSpace, ), content: SizedBox( - height: 200, + height: 220, width: 400, child: onlyPlaylists ? Padding( @@ -199,6 +199,9 @@ class _PlaylistEditDialogContentState extends State { ), ), ), + const SizedBox( + height: kLargestSpace, + ), Align( alignment: Alignment.bottomRight, child: Wrap( diff --git a/lib/podcasts/view/podcast_page.dart b/lib/podcasts/view/podcast_page.dart index 7aa77c653..a9327461b 100644 --- a/lib/podcasts/view/podcast_page.dart +++ b/lib/podcasts/view/podcast_page.dart @@ -82,6 +82,7 @@ class _PodcastPageState extends State { @override Widget build(BuildContext context) { + final l10n = context.l10n; final episodes = widget.preFetchedEpisodes ?? watchPropertyValue((LibraryModel m) => m.podcasts[widget.feedUrl]); watchPropertyValue((PlayerModel m) => m.lastPositions?.length); @@ -138,7 +139,7 @@ class _PodcastPageState extends State { label: episodesWithDownloads .firstWhereOrNull((e) => e.genre != null) ?.genre ?? - context.l10n.podcast, + l10n.podcast, subTitle: episodesWithDownloads.firstOrNull?.artist, description: episodesWithDownloads.firstOrNull?.albumArtist == null @@ -150,11 +151,11 @@ class _PodcastPageState extends State { ), title: widget.title, onLabelTab: (text) => _onGenreTap( - context: context, + l10n: l10n, text: text, ), onSubTitleTab: (text) => _onArtistTap( - context: context, + l10n: l10n, text: text, ), ), @@ -213,10 +214,10 @@ class _PodcastPageState extends State { } Future _onArtistTap({ - required BuildContext context, + required AppLocalizations l10n, required String text, }) async { - await di().init(updateMessage: context.l10n.updateAvailable); + await di().init(updateMessage: l10n.updateAvailable); di().push(pageId: kSearchPageId); di() ..setAudioType(AudioType.podcast) @@ -225,16 +226,16 @@ class _PodcastPageState extends State { } Future _onGenreTap({ - required BuildContext context, + required AppLocalizations l10n, required String text, }) async { - await di().init(updateMessage: context.l10n.updateAvailable); + await di().init(updateMessage: l10n.updateAvailable); final genres = di().getPodcastGenres(di().usePodcastIndex); final genreOrNull = genres.firstWhereOrNull( (e) => - e.localize(context.l10n).toLowerCase() == text.toLowerCase() || + e.localize(l10n).toLowerCase() == text.toLowerCase() || e.id.toLowerCase() == text.toLowerCase() || e.name.toLowerCase() == text.toLowerCase(), ); @@ -246,7 +247,7 @@ class _PodcastPageState extends State { ..search(); } else { if (context.mounted) { - _onArtistTap(context: context, text: text); + _onArtistTap(l10n: l10n, text: text); } } } diff --git a/lib/search/search_model.dart b/lib/search/search_model.dart index d9ac535d4..2075a899e 100644 --- a/lib/search/search_model.dart +++ b/lib/search/search_model.dart @@ -71,7 +71,7 @@ class SearchModel extends SafeChangeNotifier { String? get searchQuery => _searchQuery; void setSearchQuery(String? value) { if (value == _searchQuery) return; - _podcastLimit = _podcastDefaultLimit; + _podcastLimit = podcastDefaultLimit; _radioLimit = _radioDefaultLimit; _searchQuery = value; notifyListeners(); @@ -160,8 +160,8 @@ class SearchModel extends SafeChangeNotifier { ); } - static const _podcastDefaultLimit = 32; - int _podcastLimit = _podcastDefaultLimit; + static const podcastDefaultLimit = 32; + int _podcastLimit = podcastDefaultLimit; void incrementPodcastLimit(int value) => _podcastLimit += value; static const _radioDefaultLimit = 64; @@ -331,4 +331,9 @@ class SearchModel extends SafeChangeNotifier { Future?> radioNameSearch(String? searchQuery) async => _radioService.search(name: searchQuery, limit: _radioLimit); + + List? _countryCharts; + List? get countryCharts => _countryCharts; + Future radioCountrySearch() async => _countryCharts = + await _radioService.search(country: _country?.name, limit: 3); } diff --git a/lib/search/view/search_page_input.dart b/lib/search/view/search_page_input.dart index b30538d76..2e8e65392 100644 --- a/lib/search/view/search_page_input.dart +++ b/lib/search/view/search_page_input.dart @@ -35,7 +35,6 @@ class SearchPageInput extends StatelessWidget with WatchItMixin { SearchType.radioLanguage => const LanguageAutoCompleteWithSuffix(), _ => SearchInput( text: searchQuery, - key: ValueKey(searchType.name + searchQuery.toString()), hintText: context.l10n.search, onChanged: (v) async { searchModel.setSearchQuery(v); diff --git a/lib/search/view/sliver_podcast_search_results.dart b/lib/search/view/sliver_podcast_search_results.dart index b3db28fe9..50de47c30 100644 --- a/lib/search/view/sliver_podcast_search_results.dart +++ b/lib/search/view/sliver_podcast_search_results.dart @@ -20,7 +20,9 @@ import '../search_model.dart'; class SliverPodcastSearchResults extends StatefulWidget with WatchItStatefulWidgetMixin { - const SliverPodcastSearchResults({super.key}); + const SliverPodcastSearchResults({super.key, this.take}); + + final int? take; @override State createState() => @@ -52,8 +54,11 @@ class _SliverPodcastSearchResultsState final loading = watchPropertyValue((SearchModel m) => m.loading); + final results = watchPropertyValue( + (SearchModel m) => m.podcastSearchResult?.items, + ); final searchResultItems = - watchPropertyValue((SearchModel m) => m.podcastSearchResult?.items); + widget.take != null ? results?.take(widget.take!) : results; if (searchResultItems == null || searchResultItems.isEmpty) { return SliverFillNoSearchResultPage( diff --git a/lib/search/view/sliver_radio_country_grid.dart b/lib/search/view/sliver_radio_country_grid.dart new file mode 100644 index 000000000..32c93b534 --- /dev/null +++ b/lib/search/view/sliver_radio_country_grid.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:watch_it/watch_it.dart'; + +import '../../app/connectivity_model.dart'; +import '../../common/data/audio.dart'; +import '../../common/view/offline_page.dart'; +import '../../common/view/progress.dart'; +import '../../common/view/theme.dart'; +import '../../player/player_model.dart'; +import '../../radio/radio_model.dart'; +import '../../radio/view/radio_reconnect_button.dart'; +import '../../radio/view/station_card.dart'; +import '../search_model.dart'; + +class SliverRadioCountryGrid extends StatefulWidget + with WatchItStatefulWidgetMixin { + const SliverRadioCountryGrid({super.key}); + + @override + State createState() => _SliverRadioCountryGridState(); +} + +class _SliverRadioCountryGridState extends State { + @override + void initState() { + super.initState(); + di().radioCountrySearch(); + } + + @override + Widget build(BuildContext context) { + if (!watchPropertyValue((ConnectivityModel m) => m.isOnline)) { + return const SliverFillRemaining( + hasScrollBody: false, + child: OfflineBody(), + ); + } + + if (watchPropertyValue((RadioModel m) => m.connectedHost) == null) { + return const SliverToBoxAdapter( + child: Center(child: RadioReconnectButton()), + ); + } + + Iterable