mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 16:05:18 +00:00
feat: compact genre view in home page
This commit is contained in:
parent
c592cff1ee
commit
82ed5e9057
@ -158,4 +158,10 @@ abstract class FakeData {
|
|||||||
..type = "type"
|
..type = "type"
|
||||||
..description = "A very good playlist description"
|
..description = "A very good playlist description"
|
||||||
..uri = "uri";
|
..uri = "uri";
|
||||||
|
|
||||||
|
static final Category category = Category()
|
||||||
|
..href = "text"
|
||||||
|
..icons = [image]
|
||||||
|
..id = "1"
|
||||||
|
..name = "category";
|
||||||
}
|
}
|
||||||
|
232
lib/collections/gradients.dart
Normal file
232
lib/collections/gradients.dart
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
const gradients = [
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(123, 102, 255, 1),
|
||||||
|
Color.fromRGBO(95, 189, 255, 1),
|
||||||
|
Color.fromRGBO(150, 239, 255, 1),
|
||||||
|
Color.fromRGBO(197, 255, 248, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(245, 204, 160, 1),
|
||||||
|
Color.fromRGBO(228, 143, 69, 1),
|
||||||
|
Color.fromRGBO(153, 77, 28, 1),
|
||||||
|
Color.fromRGBO(107, 36, 12, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(243, 243, 243, 1),
|
||||||
|
Color.fromRGBO(197, 232, 152, 1),
|
||||||
|
Color.fromRGBO(41, 173, 178, 1),
|
||||||
|
Color.fromRGBO(7, 102, 173, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(240, 89, 65, 1),
|
||||||
|
Color.fromRGBO(190, 49, 68, 1),
|
||||||
|
Color.fromRGBO(135, 35, 65, 1),
|
||||||
|
Color.fromRGBO(34, 9, 44, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(119, 107, 93, 1),
|
||||||
|
Color.fromRGBO(176, 166, 149, 1),
|
||||||
|
Color.fromRGBO(235, 227, 213, 1),
|
||||||
|
Color.fromRGBO(243, 238, 234, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(208, 162, 247, 1),
|
||||||
|
Color.fromRGBO(220, 191, 255, 1),
|
||||||
|
Color.fromRGBO(229, 212, 255, 1),
|
||||||
|
Color.fromRGBO(241, 234, 255, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(221, 242, 253, 1),
|
||||||
|
Color.fromRGBO(155, 190, 200, 1),
|
||||||
|
Color.fromRGBO(66, 125, 157, 1),
|
||||||
|
Color.fromRGBO(22, 72, 99, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(119, 67, 219, 1),
|
||||||
|
Color.fromRGBO(195, 172, 208, 1),
|
||||||
|
Color.fromRGBO(247, 239, 229, 1),
|
||||||
|
Color.fromRGBO(255, 251, 245, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(194, 217, 255, 1),
|
||||||
|
Color.fromRGBO(142, 143, 250, 1),
|
||||||
|
Color.fromRGBO(119, 82, 254, 1),
|
||||||
|
Color.fromRGBO(25, 4, 130, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(104, 126, 255, 1),
|
||||||
|
Color.fromRGBO(128, 179, 255, 1),
|
||||||
|
Color.fromRGBO(152, 228, 255, 1),
|
||||||
|
Color.fromRGBO(182, 255, 250, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(176, 87, 141, 1),
|
||||||
|
Color.fromRGBO(217, 136, 185, 1),
|
||||||
|
Color.fromRGBO(250, 203, 234, 1),
|
||||||
|
Color.fromRGBO(255, 228, 214, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(190, 255, 247, 1),
|
||||||
|
Color.fromRGBO(166, 246, 255, 1),
|
||||||
|
Color.fromRGBO(158, 221, 255, 1),
|
||||||
|
Color.fromRGBO(100, 153, 233, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(245, 252, 205, 1),
|
||||||
|
Color.fromRGBO(120, 214, 198, 1),
|
||||||
|
Color.fromRGBO(65, 145, 151, 1),
|
||||||
|
Color.fromRGBO(18, 72, 107, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(229, 207, 247, 1),
|
||||||
|
Color.fromRGBO(157, 118, 193, 1),
|
||||||
|
Color.fromRGBO(113, 58, 190, 1),
|
||||||
|
Color.fromRGBO(91, 8, 136, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(249, 222, 201, 1),
|
||||||
|
Color.fromRGBO(247, 140, 162, 1),
|
||||||
|
Color.fromRGBO(216, 0, 50, 1),
|
||||||
|
Color.fromRGBO(61, 12, 17, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(242, 247, 161, 1),
|
||||||
|
Color.fromRGBO(53, 162, 159, 1),
|
||||||
|
Color.fromRGBO(8, 131, 149, 1),
|
||||||
|
Color.fromRGBO(7, 25, 82, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(243, 159, 90, 1),
|
||||||
|
Color.fromRGBO(174, 68, 90, 1),
|
||||||
|
Color.fromRGBO(102, 37, 73, 1),
|
||||||
|
Color.fromRGBO(69, 25, 82, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(255, 200, 200, 1),
|
||||||
|
Color.fromRGBO(255, 155, 130, 1),
|
||||||
|
Color.fromRGBO(255, 63, 164, 1),
|
||||||
|
Color.fromRGBO(87, 55, 93, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(238, 238, 238, 1),
|
||||||
|
Color.fromRGBO(100, 204, 197, 1),
|
||||||
|
Color.fromRGBO(23, 107, 135, 1),
|
||||||
|
Color.fromRGBO(5, 59, 80, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(198, 61, 47, 1),
|
||||||
|
Color.fromRGBO(226, 94, 62, 1),
|
||||||
|
Color.fromRGBO(255, 155, 80, 1),
|
||||||
|
Color.fromRGBO(255, 187, 92, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(236, 83, 176, 1),
|
||||||
|
Color.fromRGBO(157, 68, 192, 1),
|
||||||
|
Color.fromRGBO(77, 45, 183, 1),
|
||||||
|
Color.fromRGBO(14, 33, 160, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(242, 236, 190, 1),
|
||||||
|
Color.fromRGBO(226, 199, 153, 1),
|
||||||
|
Color.fromRGBO(192, 130, 97, 1),
|
||||||
|
Color.fromRGBO(154, 59, 59, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(255, 253, 140, 1),
|
||||||
|
Color.fromRGBO(151, 255, 244, 1),
|
||||||
|
Color.fromRGBO(112, 145, 245, 1),
|
||||||
|
Color.fromRGBO(121, 63, 223, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(67, 83, 52, 1),
|
||||||
|
Color.fromRGBO(158, 179, 132, 1),
|
||||||
|
Color.fromRGBO(206, 222, 189, 1),
|
||||||
|
Color.fromRGBO(250, 241, 228, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(250, 240, 230, 1),
|
||||||
|
Color.fromRGBO(185, 180, 199, 1),
|
||||||
|
Color.fromRGBO(92, 84, 112, 1),
|
||||||
|
Color.fromRGBO(53, 47, 68, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(255, 186, 134, 1),
|
||||||
|
Color.fromRGBO(246, 99, 92, 1),
|
||||||
|
Color.fromRGBO(194, 51, 115, 1),
|
||||||
|
Color.fromRGBO(121, 21, 91, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(213, 255, 208, 1),
|
||||||
|
Color.fromRGBO(64, 248, 255, 1),
|
||||||
|
Color.fromRGBO(39, 158, 255, 1),
|
||||||
|
Color.fromRGBO(12, 53, 106, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(131, 96, 150, 1),
|
||||||
|
Color.fromRGBO(237, 123, 123, 1),
|
||||||
|
Color.fromRGBO(240, 184, 110, 1),
|
||||||
|
Color.fromRGBO(235, 231, 108, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(63, 29, 56, 1),
|
||||||
|
Color.fromRGBO(77, 60, 119, 1),
|
||||||
|
Color.fromRGBO(162, 103, 138, 1),
|
||||||
|
Color.fromRGBO(225, 152, 152, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(254, 123, 229, 1),
|
||||||
|
Color.fromRGBO(151, 78, 195, 1),
|
||||||
|
Color.fromRGBO(80, 64, 153, 1),
|
||||||
|
Color.fromRGBO(49, 56, 102, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(248, 222, 34, 1),
|
||||||
|
Color.fromRGBO(249, 76, 16, 1),
|
||||||
|
Color.fromRGBO(199, 0, 57, 1),
|
||||||
|
Color.fromRGBO(144, 12, 63, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(101, 69, 31, 1),
|
||||||
|
Color.fromRGBO(118, 88, 39, 1),
|
||||||
|
Color.fromRGBO(200, 174, 125, 1),
|
||||||
|
Color.fromRGBO(234, 198, 150, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(255, 246, 224, 1),
|
||||||
|
Color.fromRGBO(216, 217, 218, 1),
|
||||||
|
Color.fromRGBO(97, 103, 122, 1),
|
||||||
|
Color.fromRGBO(39, 40, 41, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(145, 109, 179, 1),
|
||||||
|
Color.fromRGBO(228, 133, 134, 1),
|
||||||
|
Color.fromRGBO(252, 186, 173, 1),
|
||||||
|
Color.fromRGBO(253, 229, 236, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(124, 115, 192, 1),
|
||||||
|
Color.fromRGBO(148, 173, 215, 1),
|
||||||
|
Color.fromRGBO(172, 250, 223, 1),
|
||||||
|
Color.fromRGBO(232, 255, 206, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(174, 216, 204, 1),
|
||||||
|
Color.fromRGBO(205, 102, 136, 1),
|
||||||
|
Color.fromRGBO(122, 49, 111, 1),
|
||||||
|
Color.fromRGBO(70, 25, 89, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(237, 228, 255, 1),
|
||||||
|
Color.fromRGBO(215, 187, 245, 1),
|
||||||
|
Color.fromRGBO(160, 118, 249, 1),
|
||||||
|
Color.fromRGBO(101, 40, 247, 1)
|
||||||
|
]),
|
||||||
|
LinearGradient(colors: [
|
||||||
|
Color.fromRGBO(255, 236, 175, 1),
|
||||||
|
Color.fromRGBO(255, 176, 127, 1),
|
||||||
|
Color.fromRGBO(255, 82, 162, 1),
|
||||||
|
Color.fromRGBO(243, 21, 89, 1)
|
||||||
|
]),
|
||||||
|
];
|
@ -1,9 +1,11 @@
|
|||||||
import 'package:catcher_2/catcher_2.dart';
|
import 'package:catcher_2/catcher_2.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart' hide Category;
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:spotify/spotify.dart' hide Search;
|
import 'package:spotify/spotify.dart' hide Search;
|
||||||
import 'package:spotube/pages/album/album.dart';
|
import 'package:spotube/pages/album/album.dart';
|
||||||
|
import 'package:spotube/pages/home/genres/genre_playlists.dart';
|
||||||
|
import 'package:spotube/pages/home/genres/genres.dart';
|
||||||
import 'package:spotube/pages/home/home.dart';
|
import 'package:spotube/pages/home/home.dart';
|
||||||
import 'package:spotube/pages/lastfm_login/lastfm_login.dart';
|
import 'package:spotube/pages/lastfm_login/lastfm_login.dart';
|
||||||
import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart';
|
import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart';
|
||||||
@ -38,6 +40,21 @@ final router = GoRouter(
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: "/",
|
path: "/",
|
||||||
pageBuilder: (context, state) => const SpotubePage(child: HomePage()),
|
pageBuilder: (context, state) => const SpotubePage(child: HomePage()),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: "genres",
|
||||||
|
pageBuilder: (context, state) =>
|
||||||
|
const SpotubePage(child: GenrePage()),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: "genre/:categoryId",
|
||||||
|
pageBuilder: (context, state) => SpotubePage(
|
||||||
|
child: GenrePlaylistsPage(
|
||||||
|
category: state.extra as Category,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: "/search",
|
path: "/search",
|
||||||
|
@ -1,51 +0,0 @@
|
|||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:flutter/material.dart' hide Page;
|
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
|
|
||||||
import 'package:spotify/spotify.dart';
|
|
||||||
import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart';
|
|
||||||
import 'package:spotube/models/logger.dart';
|
|
||||||
import 'package:spotube/services/queries/queries.dart';
|
|
||||||
|
|
||||||
class CategoryCard extends HookConsumerWidget {
|
|
||||||
final Category category;
|
|
||||||
CategoryCard(
|
|
||||||
this.category, {
|
|
||||||
Key? key,
|
|
||||||
}) : super(key: key);
|
|
||||||
|
|
||||||
final logger = getLogger(CategoryCard);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, ref) {
|
|
||||||
final playlistQuery = useQueries.category.playlistsOf(
|
|
||||||
ref,
|
|
||||||
category.id!,
|
|
||||||
);
|
|
||||||
|
|
||||||
final playlists = useMemoized(
|
|
||||||
() => playlistQuery.pages.expand(
|
|
||||||
(page) {
|
|
||||||
return page.items?.whereNotNull() ??
|
|
||||||
const Iterable<PlaylistSimple>.empty();
|
|
||||||
},
|
|
||||||
).toList(),
|
|
||||||
[playlistQuery.pages],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (playlistQuery.hasErrors &&
|
|
||||||
!playlistQuery.hasPageData &&
|
|
||||||
!playlistQuery.isLoadingNextPage) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
return HorizontalPlaybuttonCardView<PlaylistSimple>(
|
|
||||||
title: Text(category.name!),
|
|
||||||
isLoadingNextPage: playlistQuery.isLoadingNextPage,
|
|
||||||
hasNextPage: playlistQuery.hasNextPage,
|
|
||||||
items: playlists,
|
|
||||||
onFetchMore: playlistQuery.fetchNext,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
36
lib/components/home/sections/featured.dart
Normal file
36
lib/components/home/sections/featured.dart
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import 'package:flutter/material.dart' hide Page;
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
|
import 'package:spotify/spotify.dart';
|
||||||
|
import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart';
|
||||||
|
import 'package:spotube/extensions/context.dart';
|
||||||
|
import 'package:spotube/services/queries/queries.dart';
|
||||||
|
|
||||||
|
class HomeFeaturedSection extends HookConsumerWidget {
|
||||||
|
const HomeFeaturedSection({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, ref) {
|
||||||
|
final featuredPlaylistsQuery = useQueries.playlist.featured(ref);
|
||||||
|
final playlists = useMemoized(
|
||||||
|
() => featuredPlaylistsQuery.pages
|
||||||
|
.whereType<Page<PlaylistSimple>>()
|
||||||
|
.expand((page) => page.items ?? const <PlaylistSimple>[]),
|
||||||
|
[featuredPlaylistsQuery.pages],
|
||||||
|
);
|
||||||
|
final isLoadingFeaturedPlaylists = !featuredPlaylistsQuery.hasPageData &&
|
||||||
|
!featuredPlaylistsQuery.isLoadingNextPage;
|
||||||
|
|
||||||
|
return Skeletonizer(
|
||||||
|
enabled: isLoadingFeaturedPlaylists,
|
||||||
|
child: HorizontalPlaybuttonCardView<PlaylistSimple>(
|
||||||
|
items: playlists.toList(),
|
||||||
|
title: Text(context.l10n.featured),
|
||||||
|
isLoadingNextPage: featuredPlaylistsQuery.isLoadingNextPage,
|
||||||
|
hasNextPage: featuredPlaylistsQuery.hasNextPage,
|
||||||
|
onFetchMore: featuredPlaylistsQuery.fetchNext,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
154
lib/components/home/sections/genres.dart
Normal file
154
lib/components/home/sections/genres.dart
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
|
import 'package:spotify/spotify.dart';
|
||||||
|
import 'package:spotube/collections/fake.dart';
|
||||||
|
import 'package:spotube/collections/gradients.dart';
|
||||||
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
|
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||||
|
import 'package:spotube/extensions/constrains.dart';
|
||||||
|
import 'package:spotube/extensions/context.dart';
|
||||||
|
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||||
|
import 'package:spotube/services/queries/queries.dart';
|
||||||
|
|
||||||
|
class HomeGenresSection extends HookConsumerWidget {
|
||||||
|
const HomeGenresSection({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, ref) {
|
||||||
|
final ThemeData(:textTheme, :colorScheme) = Theme.of(context);
|
||||||
|
final mediaQuery = MediaQuery.of(context);
|
||||||
|
|
||||||
|
final recommendationMarket = ref.watch(
|
||||||
|
userPreferencesProvider.select((s) => s.recommendationMarket),
|
||||||
|
);
|
||||||
|
final categoriesQuery =
|
||||||
|
useQueries.category.listAll(ref, recommendationMarket);
|
||||||
|
|
||||||
|
final categories = categoriesQuery.data
|
||||||
|
?.where((c) => (c.icons?.length ?? 0) > 0)
|
||||||
|
.take(mediaQuery.mdAndDown ? 6 : 10)
|
||||||
|
.toList() ??
|
||||||
|
<Category>[];
|
||||||
|
|
||||||
|
return SliverMainAxisGroup(
|
||||||
|
slivers: [
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
context.l10n.genre,
|
||||||
|
style: textTheme.headlineSmall,
|
||||||
|
),
|
||||||
|
Directionality(
|
||||||
|
textDirection: TextDirection.rtl,
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
context.push('/genres');
|
||||||
|
},
|
||||||
|
icon: const Icon(SpotubeIcons.angleRight),
|
||||||
|
label: Text(
|
||||||
|
"Browse All",
|
||||||
|
style: textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.secondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SliverGap(8),
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
sliver: Skeletonizer.sliver(
|
||||||
|
enabled: categoriesQuery.isLoading,
|
||||||
|
child: SliverGrid.builder(
|
||||||
|
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
|
maxCrossAxisExtent: mediaQuery.mdAndDown ? 200 : 250,
|
||||||
|
mainAxisExtent: 50,
|
||||||
|
crossAxisSpacing: 16,
|
||||||
|
mainAxisSpacing: 16,
|
||||||
|
),
|
||||||
|
itemCount: categoriesQuery.isLoading
|
||||||
|
? mediaQuery.mdAndDown
|
||||||
|
? 6
|
||||||
|
: 10
|
||||||
|
: categories.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final category =
|
||||||
|
categories.elementAtOrNull(index) ?? FakeData.category;
|
||||||
|
|
||||||
|
return HookBuilder(builder: (context) {
|
||||||
|
final (:gradient, :textColor) = useMemoized(
|
||||||
|
() {
|
||||||
|
final gradient =
|
||||||
|
gradients[Random().nextInt(gradients.length)];
|
||||||
|
final text = gradient.colors
|
||||||
|
.take(2)
|
||||||
|
.any((c) => c.computeLuminance() > 0.5)
|
||||||
|
? Colors.grey[900]
|
||||||
|
: Colors.white;
|
||||||
|
return (
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: gradient.colors
|
||||||
|
.map((c) => c.withOpacity(0.8))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
textColor: text
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: () {
|
||||||
|
context.push('/genre/${category.id}', extra: category);
|
||||||
|
},
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Ink(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
image: DecorationImage(
|
||||||
|
image: UniversalImage.imageProvider(
|
||||||
|
category.icons!.first.url!,
|
||||||
|
),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Ink(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: colorScheme.surfaceVariant,
|
||||||
|
gradient: categoriesQuery.isLoading ? null : gradient,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
category.name!,
|
||||||
|
style: textTheme.titleMedium
|
||||||
|
?.copyWith(color: textColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
35
lib/components/home/sections/made_for_user.dart
Normal file
35
lib/components/home/sections/made_for_user.dart
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:spotify/spotify.dart';
|
||||||
|
import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart';
|
||||||
|
import 'package:spotube/services/queries/queries.dart';
|
||||||
|
|
||||||
|
class HomeMadeForUserSection extends HookConsumerWidget {
|
||||||
|
const HomeMadeForUserSection({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, ref) {
|
||||||
|
final madeForUser = useQueries.views.get(ref, "made-for-x-hub");
|
||||||
|
|
||||||
|
return SliverList.builder(
|
||||||
|
itemCount: madeForUser.data?["content"]?["items"]?.length ?? 0,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = madeForUser.data?["content"]?["items"]?[index];
|
||||||
|
final playlists = item["content"]?["items"]
|
||||||
|
?.where((itemL2) => itemL2["type"] == "playlist")
|
||||||
|
.map((itemL2) => PlaylistSimple.fromJson(itemL2))
|
||||||
|
.toList()
|
||||||
|
.cast<PlaylistSimple>() ??
|
||||||
|
<PlaylistSimple>[];
|
||||||
|
if (playlists.isEmpty) return const SizedBox.shrink();
|
||||||
|
return HorizontalPlaybuttonCardView<PlaylistSimple>(
|
||||||
|
items: playlists,
|
||||||
|
title: Text(item["name"] ?? ""),
|
||||||
|
hasNextPage: false,
|
||||||
|
isLoadingNextPage: false,
|
||||||
|
onFetchMore: () {},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
51
lib/components/home/sections/new_releases.dart
Normal file
51
lib/components/home/sections/new_releases.dart
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import 'package:flutter/material.dart' hide Page;
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:spotify/spotify.dart';
|
||||||
|
import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart';
|
||||||
|
import 'package:spotube/extensions/context.dart';
|
||||||
|
import 'package:spotube/provider/authentication_provider.dart';
|
||||||
|
import 'package:spotube/services/queries/queries.dart';
|
||||||
|
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||||
|
|
||||||
|
class HomeNewReleasesSection extends HookConsumerWidget {
|
||||||
|
const HomeNewReleasesSection({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, ref) {
|
||||||
|
final auth = ref.watch(AuthenticationNotifier.provider);
|
||||||
|
|
||||||
|
final newReleases = useQueries.album.newReleases(ref);
|
||||||
|
final userArtistsQuery = useQueries.artist.followedByMeAll(ref);
|
||||||
|
final userArtists =
|
||||||
|
userArtistsQuery.data?.map((s) => s.id!).toList() ?? const [];
|
||||||
|
|
||||||
|
final albums = useMemoized(
|
||||||
|
() => newReleases.pages
|
||||||
|
.whereType<Page<AlbumSimple>>()
|
||||||
|
.expand((page) => page.items ?? const <AlbumSimple>[])
|
||||||
|
.where((album) {
|
||||||
|
return album.artists
|
||||||
|
?.any((artist) => userArtists.contains(artist.id!)) ==
|
||||||
|
true;
|
||||||
|
})
|
||||||
|
.map((album) => TypeConversionUtils.simpleAlbum_X_Album(album))
|
||||||
|
.toList(),
|
||||||
|
[newReleases.pages],
|
||||||
|
);
|
||||||
|
|
||||||
|
final hasNewReleases = newReleases.hasPageData &&
|
||||||
|
userArtistsQuery.hasData &&
|
||||||
|
!newReleases.isLoadingNextPage;
|
||||||
|
|
||||||
|
if (auth == null || !hasNewReleases) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return HorizontalPlaybuttonCardView<Album>(
|
||||||
|
items: albums,
|
||||||
|
title: Text(context.l10n.new_releases),
|
||||||
|
isLoadingNextPage: newReleases.isLoadingNextPage,
|
||||||
|
hasNextPage: newReleases.hasNextPage,
|
||||||
|
onFetchMore: newReleases.fetchNext,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -4,10 +4,10 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
|||||||
import 'package:gap/gap.dart';
|
import 'package:gap/gap.dart';
|
||||||
import 'package:skeletonizer/skeletonizer.dart';
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
|
|
||||||
import 'package:spotube/collections/assets.gen.dart';
|
|
||||||
import 'package:spotube/collections/spotube_icons.dart';
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
import 'package:spotube/components/shared/hover_builder.dart';
|
import 'package:spotube/components/shared/hover_builder.dart';
|
||||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||||
|
import 'package:spotube/extensions/constrains.dart';
|
||||||
import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
|
import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
|
||||||
import 'package:spotube/hooks/utils/use_brightness_value.dart';
|
import 'package:spotube/hooks/utils/use_brightness_value.dart';
|
||||||
|
|
||||||
@ -50,6 +50,7 @@ class PlaybuttonCard extends HookWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final textsKey = useMemoized(() => GlobalKey(), []);
|
final textsKey = useMemoized(() => GlobalKey(), []);
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final mediaQuery = MediaQuery.of(context);
|
||||||
final radius = BorderRadius.circular(15);
|
final radius = BorderRadius.circular(15);
|
||||||
|
|
||||||
final double size = useBreakpointValue<double>(
|
final double size = useBreakpointValue<double>(
|
||||||
@ -86,23 +87,27 @@ class PlaybuttonCard extends HookWidget {
|
|||||||
splashFactory: theme.splashFactory,
|
splashFactory: theme.splashFactory,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Stack(
|
Stack(
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
|
margin: const EdgeInsets.fromLTRB(8, 8, 8, 0),
|
||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.only(
|
||||||
left: 8,
|
left: 8,
|
||||||
right: 8,
|
right: 8,
|
||||||
top: 8,
|
top: 8,
|
||||||
),
|
),
|
||||||
constraints: BoxConstraints(maxHeight: size),
|
height: mediaQuery.smAndDown
|
||||||
child: ClipRRect(
|
? 120
|
||||||
|
: mediaQuery.mdAndDown
|
||||||
|
? 130
|
||||||
|
: 150,
|
||||||
|
decoration: BoxDecoration(
|
||||||
borderRadius: radius,
|
borderRadius: radius,
|
||||||
child: UniversalImage(
|
image: DecorationImage(
|
||||||
path: imageUrl,
|
image: UniversalImage.imageProvider(imageUrl),
|
||||||
placeholder: Assets.albumPlaceholder.path,
|
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -1,151 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:skeletonizer/skeletonizer.dart';
|
|
||||||
import 'package:spotify/spotify.dart';
|
|
||||||
import 'package:spotube/collections/spotube_icons.dart';
|
|
||||||
import 'package:spotube/components/genre/category_card.dart';
|
|
||||||
import 'package:spotube/components/shared/expandable_search/expandable_search.dart';
|
|
||||||
import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart';
|
|
||||||
import 'package:spotube/components/shared/waypoint.dart';
|
|
||||||
|
|
||||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
|
||||||
import 'package:spotube/services/queries/queries.dart';
|
|
||||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
|
|
||||||
|
|
||||||
class GenrePage extends HookConsumerWidget {
|
|
||||||
const GenrePage({Key? key}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, ref) {
|
|
||||||
final scrollController = useScrollController();
|
|
||||||
final recommendationMarket = ref.watch(
|
|
||||||
userPreferencesProvider.select((s) => s.recommendationMarket),
|
|
||||||
);
|
|
||||||
final categoriesQuery = useQueries.category.list(ref, recommendationMarket);
|
|
||||||
final isFiltering = useState(false);
|
|
||||||
|
|
||||||
final isMounted = useIsMounted();
|
|
||||||
|
|
||||||
final searchController = useTextEditingController();
|
|
||||||
final searchFocus = useFocusNode();
|
|
||||||
|
|
||||||
useValueListenable(searchController);
|
|
||||||
|
|
||||||
final categories = useMemoized(
|
|
||||||
() {
|
|
||||||
final categories = categoriesQuery.pages
|
|
||||||
.expand<Category>(
|
|
||||||
(page) => page.items ?? const Iterable.empty(),
|
|
||||||
)
|
|
||||||
.toList();
|
|
||||||
if (searchController.text.isEmpty) {
|
|
||||||
return categories;
|
|
||||||
}
|
|
||||||
return categories
|
|
||||||
.map((e) => (
|
|
||||||
weightedRatio(e.name!, searchController.text),
|
|
||||||
e,
|
|
||||||
))
|
|
||||||
.sorted((a, b) => b.$1.compareTo(a.$1))
|
|
||||||
.where((e) => e.$1 > 50)
|
|
||||||
.map((e) => e.$2)
|
|
||||||
.toList();
|
|
||||||
},
|
|
||||||
[categoriesQuery.pages, searchController.text],
|
|
||||||
);
|
|
||||||
|
|
||||||
final list = RefreshIndicator(
|
|
||||||
onRefresh: () async {
|
|
||||||
await categoriesQuery.refreshAll();
|
|
||||||
},
|
|
||||||
child: Waypoint(
|
|
||||||
onTouchEdge: () async {
|
|
||||||
if (categoriesQuery.hasNextPage && isMounted()) {
|
|
||||||
await categoriesQuery.fetchNext();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
controller: scrollController,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
ExpandableSearchField(
|
|
||||||
isFiltering: isFiltering.value,
|
|
||||||
onChangeFiltering: (value) => isFiltering.value = value,
|
|
||||||
searchController: searchController,
|
|
||||||
searchFocus: searchFocus,
|
|
||||||
),
|
|
||||||
if (!categoriesQuery.hasPageData &&
|
|
||||||
!categoriesQuery.isLoadingNextPage)
|
|
||||||
Expanded(
|
|
||||||
child: Skeletonizer(
|
|
||||||
enabled: true,
|
|
||||||
child: ListView.builder(
|
|
||||||
itemCount: 5,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
return HorizontalPlaybuttonCardView<PlaylistSimple>(
|
|
||||||
title: const Text("Loading"),
|
|
||||||
items: const [],
|
|
||||||
hasNextPage: true,
|
|
||||||
isLoadingNextPage: false,
|
|
||||||
onFetchMore: () {},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
Expanded(
|
|
||||||
child: InfiniteList(
|
|
||||||
scrollController: scrollController,
|
|
||||||
itemCount: categories.length,
|
|
||||||
onFetchData: categoriesQuery.fetchNext,
|
|
||||||
isLoading: categoriesQuery.isLoadingNextPage,
|
|
||||||
hasReachedMax: !categoriesQuery.hasNextPage,
|
|
||||||
loadingBuilder: (context) => Skeletonizer(
|
|
||||||
enabled: true,
|
|
||||||
child: HorizontalPlaybuttonCardView<PlaylistSimple>(
|
|
||||||
title: const Text("Loading"),
|
|
||||||
items: const [],
|
|
||||||
hasNextPage: true,
|
|
||||||
isLoadingNextPage: false,
|
|
||||||
onFetchMore: () {},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
return CategoryCard(categories[index]);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return Stack(
|
|
||||||
children: [
|
|
||||||
Positioned.fill(child: list),
|
|
||||||
Positioned(
|
|
||||||
top: 0,
|
|
||||||
right: 10,
|
|
||||||
child: ExpandableSearchButton(
|
|
||||||
isFiltering: isFiltering.value,
|
|
||||||
searchFocus: searchFocus,
|
|
||||||
icon: const Icon(SpotubeIcons.search),
|
|
||||||
onPressed: (value) {
|
|
||||||
isFiltering.value = value;
|
|
||||||
if (isFiltering.value) {
|
|
||||||
scrollController.animateTo(
|
|
||||||
0,
|
|
||||||
duration: const Duration(milliseconds: 300),
|
|
||||||
curve: Curves.easeInOut,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
165
lib/pages/home/genres/genre_playlists.dart
Normal file
165
lib/pages/home/genres/genre_playlists.dart
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
|
import 'package:spotify/spotify.dart' hide Offset;
|
||||||
|
import 'package:spotube/collections/fake.dart';
|
||||||
|
import 'package:spotube/components/playlist/playlist_card.dart';
|
||||||
|
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||||
|
import 'package:spotube/components/shared/page_window_title_bar.dart';
|
||||||
|
import 'package:spotube/components/shared/waypoint.dart';
|
||||||
|
import 'package:spotube/extensions/constrains.dart';
|
||||||
|
import 'package:spotube/services/queries/queries.dart';
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
|
||||||
|
class GenrePlaylistsPage extends HookConsumerWidget {
|
||||||
|
final Category category;
|
||||||
|
const GenrePlaylistsPage({Key? key, required this.category})
|
||||||
|
: super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, ref) {
|
||||||
|
final playlistsQuery = useQueries.category.playlistsOf(
|
||||||
|
ref,
|
||||||
|
category.id!,
|
||||||
|
);
|
||||||
|
|
||||||
|
final playlists = useMemoized(
|
||||||
|
() => playlistsQuery.pages.expand(
|
||||||
|
(page) {
|
||||||
|
return page.items?.whereNotNull() ??
|
||||||
|
const Iterable<PlaylistSimple>.empty();
|
||||||
|
},
|
||||||
|
).toList(),
|
||||||
|
[playlistsQuery.pages],
|
||||||
|
);
|
||||||
|
|
||||||
|
final mediaQuery = MediaQuery.of(context);
|
||||||
|
|
||||||
|
final scrollController = useScrollController();
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: const PageWindowTitleBar(
|
||||||
|
automaticallyImplyLeading: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
),
|
||||||
|
extendBodyBehindAppBar: true,
|
||||||
|
body: CustomScrollView(
|
||||||
|
controller: scrollController,
|
||||||
|
slivers: [
|
||||||
|
SliverAppBar(
|
||||||
|
automaticallyImplyLeading: false,
|
||||||
|
expandedHeight: mediaQuery.mdAndDown ? 200 : 250,
|
||||||
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
|
stretchModes: const [
|
||||||
|
StretchMode.zoomBackground,
|
||||||
|
StretchMode.blurBackground,
|
||||||
|
],
|
||||||
|
background: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
image: DecorationImage(
|
||||||
|
image: UniversalImage.imageProvider(
|
||||||
|
category.icons!.first.url!,
|
||||||
|
),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||||
|
child: const ColoredBox(color: Colors.transparent),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
centerTitle: true,
|
||||||
|
title: Text(
|
||||||
|
category.name!,
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||||
|
letterSpacing: 3,
|
||||||
|
shadows: [
|
||||||
|
const Shadow(
|
||||||
|
offset: Offset(-1.5, -1.5),
|
||||||
|
color: Colors.black54,
|
||||||
|
),
|
||||||
|
const Shadow(
|
||||||
|
offset: Offset(1.5, -1.5),
|
||||||
|
color: Colors.black54,
|
||||||
|
),
|
||||||
|
const Shadow(
|
||||||
|
offset: Offset(1.5, 1.5),
|
||||||
|
color: Colors.black54,
|
||||||
|
),
|
||||||
|
const Shadow(
|
||||||
|
offset: Offset(-1.5, 1.5),
|
||||||
|
color: Colors.black54,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
collapseMode: CollapseMode.parallax,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SliverGap(20),
|
||||||
|
SliverSafeArea(
|
||||||
|
top: false,
|
||||||
|
sliver: SliverPadding(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: mediaQuery.mdAndDown ? 12 : 24,
|
||||||
|
),
|
||||||
|
sliver: playlists.isEmpty
|
||||||
|
? Skeletonizer.sliver(
|
||||||
|
child: SliverToBoxAdapter(
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 12,
|
||||||
|
children: List.generate(
|
||||||
|
6,
|
||||||
|
(index) => PlaylistCard(FakeData.playlist),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: SliverGrid.builder(
|
||||||
|
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
|
maxCrossAxisExtent: 190,
|
||||||
|
mainAxisExtent: mediaQuery.mdAndDown ? 225 : 250,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
),
|
||||||
|
itemCount: playlists.length + 1,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final playlist = playlists.elementAtOrNull(index);
|
||||||
|
|
||||||
|
if (playlist == null) {
|
||||||
|
if (!playlistsQuery.hasNextPage) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return Skeletonizer(
|
||||||
|
enabled: true,
|
||||||
|
child: Waypoint(
|
||||||
|
controller: scrollController,
|
||||||
|
isGrid: true,
|
||||||
|
onTouchEdge: () async {
|
||||||
|
if (playlistsQuery.hasNextPage) {
|
||||||
|
await playlistsQuery.fetchNext();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: PlaylistCard(FakeData.playlist),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Skeleton.keep(
|
||||||
|
child: PlaylistCard(playlist),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SliverGap(20),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
89
lib/pages/home/genres/genres.dart
Normal file
89
lib/pages/home/genres/genres.dart
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import 'package:auto_size_text/auto_size_text.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
|
import 'package:spotify/spotify.dart' hide Offset;
|
||||||
|
import 'package:spotube/components/shared/page_window_title_bar.dart';
|
||||||
|
import 'package:spotube/extensions/constrains.dart';
|
||||||
|
|
||||||
|
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||||
|
import 'package:spotube/services/queries/queries.dart';
|
||||||
|
|
||||||
|
class GenrePage extends HookConsumerWidget {
|
||||||
|
const GenrePage({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, ref) {
|
||||||
|
final ThemeData(:textTheme) = Theme.of(context);
|
||||||
|
final scrollController = useScrollController();
|
||||||
|
final recommendationMarket = ref.watch(
|
||||||
|
userPreferencesProvider.select((s) => s.recommendationMarket),
|
||||||
|
);
|
||||||
|
final categoriesQuery =
|
||||||
|
useQueries.category.listAll(ref, recommendationMarket);
|
||||||
|
|
||||||
|
final categories = categoriesQuery.data ?? <Category>[];
|
||||||
|
|
||||||
|
final mediaQuery = MediaQuery.of(context);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: const PageWindowTitleBar(automaticallyImplyLeading: true),
|
||||||
|
body: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: GridView.builder(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
controller: scrollController,
|
||||||
|
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
|
childAspectRatio: 9 / 18,
|
||||||
|
maxCrossAxisExtent: mediaQuery.smAndDown ? 200 : 300,
|
||||||
|
mainAxisExtent: 200,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
),
|
||||||
|
itemCount: categories.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final category = categories[index];
|
||||||
|
return InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
onTap: () {
|
||||||
|
context.push("/genre/${category.id}", extra: category);
|
||||||
|
},
|
||||||
|
child: Ink(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
image: DecorationImage(
|
||||||
|
image: NetworkImage(category.icons!.first.url!),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: AutoSizeText(
|
||||||
|
category.name!,
|
||||||
|
style: textTheme.titleLarge?.copyWith(
|
||||||
|
shadows: [
|
||||||
|
// stroke shadow
|
||||||
|
const Shadow(
|
||||||
|
color: Colors.black,
|
||||||
|
offset: Offset(1, 1),
|
||||||
|
blurRadius: 2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxFontSize: textTheme.titleLarge!.fontSize!,
|
||||||
|
minFontSize: textTheme.titleMedium!.fontSize!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,35 +1,33 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:spotube/components/home/sections/featured.dart';
|
||||||
|
import 'package:spotube/components/home/sections/genres.dart';
|
||||||
|
import 'package:spotube/components/home/sections/made_for_user.dart';
|
||||||
|
import 'package:spotube/components/home/sections/new_releases.dart';
|
||||||
import 'package:spotube/components/shared/page_window_title_bar.dart';
|
import 'package:spotube/components/shared/page_window_title_bar.dart';
|
||||||
import 'package:spotube/components/shared/themed_button_tab_bar.dart';
|
|
||||||
import 'package:spotube/extensions/context.dart';
|
|
||||||
import 'package:spotube/pages/home/genres.dart';
|
|
||||||
import 'package:spotube/pages/home/personalized.dart';
|
|
||||||
|
|
||||||
class HomePage extends HookConsumerWidget {
|
class HomePage extends HookConsumerWidget {
|
||||||
const HomePage({Key? key}) : super(key: key);
|
const HomePage({Key? key}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
return DefaultTabController(
|
final controller = useScrollController();
|
||||||
length: 2,
|
|
||||||
child: Scaffold(
|
return Scaffold(
|
||||||
appBar: PageWindowTitleBar(
|
appBar: const PageWindowTitleBar(),
|
||||||
centerTitle: true,
|
body: CustomScrollView(
|
||||||
leadingWidth: double.infinity,
|
controller: controller,
|
||||||
leading: ThemedButtonsTabBar(
|
slivers: [
|
||||||
tabs: [
|
const HomeGenresSection(),
|
||||||
Tab(text: " ${context.l10n.personalized} "),
|
SliverList.list(
|
||||||
Tab(text: " ${context.l10n.genre} "),
|
children: const [
|
||||||
|
HomeFeaturedSection(),
|
||||||
|
HomeNewReleasesSection(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
const SliverSafeArea(sliver: HomeMadeForUserSection()),
|
||||||
body: const TabBarView(
|
],
|
||||||
children: [
|
|
||||||
PersonalizedPage(),
|
|
||||||
GenrePage(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,106 +0,0 @@
|
|||||||
import 'package:flutter/material.dart' hide Page;
|
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
|
|
||||||
import 'package:spotify/spotify.dart';
|
|
||||||
import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart';
|
|
||||||
import 'package:spotube/extensions/context.dart';
|
|
||||||
import 'package:spotube/provider/authentication_provider.dart';
|
|
||||||
import 'package:spotube/services/queries/queries.dart';
|
|
||||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
|
||||||
import 'package:skeletonizer/skeletonizer.dart';
|
|
||||||
|
|
||||||
class PersonalizedPage extends HookConsumerWidget {
|
|
||||||
const PersonalizedPage({Key? key}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, ref) {
|
|
||||||
final controller = useScrollController();
|
|
||||||
final auth = ref.watch(AuthenticationNotifier.provider);
|
|
||||||
final featuredPlaylistsQuery = useQueries.playlist.featured(ref);
|
|
||||||
final playlists = useMemoized(
|
|
||||||
() => featuredPlaylistsQuery.pages
|
|
||||||
.whereType<Page<PlaylistSimple>>()
|
|
||||||
.expand((page) => page.items ?? const <PlaylistSimple>[]),
|
|
||||||
[featuredPlaylistsQuery.pages],
|
|
||||||
);
|
|
||||||
|
|
||||||
final madeForUser = useQueries.views.get(ref, "made-for-x-hub");
|
|
||||||
|
|
||||||
final newReleases = useQueries.album.newReleases(ref);
|
|
||||||
final userArtistsQuery = useQueries.artist.followedByMeAll(ref);
|
|
||||||
final userArtists =
|
|
||||||
userArtistsQuery.data?.map((s) => s.id!).toList() ?? const [];
|
|
||||||
|
|
||||||
final albums = useMemoized(
|
|
||||||
() => newReleases.pages
|
|
||||||
.whereType<Page<AlbumSimple>>()
|
|
||||||
.expand((page) => page.items ?? const <AlbumSimple>[])
|
|
||||||
.where((album) {
|
|
||||||
return album.artists
|
|
||||||
?.any((artist) => userArtists.contains(artist.id!)) ==
|
|
||||||
true;
|
|
||||||
})
|
|
||||||
.map((album) => TypeConversionUtils.simpleAlbum_X_Album(album))
|
|
||||||
.toList(),
|
|
||||||
[newReleases.pages],
|
|
||||||
);
|
|
||||||
|
|
||||||
final hasNewReleases = newReleases.hasPageData &&
|
|
||||||
userArtistsQuery.hasData &&
|
|
||||||
!newReleases.isLoadingNextPage;
|
|
||||||
|
|
||||||
final isLoadingFeaturedPlaylists = !featuredPlaylistsQuery.hasPageData &&
|
|
||||||
!featuredPlaylistsQuery.isLoadingNextPage;
|
|
||||||
|
|
||||||
return CustomScrollView(
|
|
||||||
controller: controller,
|
|
||||||
slivers: [
|
|
||||||
SliverList.list(
|
|
||||||
children: [
|
|
||||||
Skeletonizer(
|
|
||||||
enabled: isLoadingFeaturedPlaylists,
|
|
||||||
child: HorizontalPlaybuttonCardView<PlaylistSimple>(
|
|
||||||
items: playlists.toList(),
|
|
||||||
title: Text(context.l10n.featured),
|
|
||||||
isLoadingNextPage: featuredPlaylistsQuery.isLoadingNextPage,
|
|
||||||
hasNextPage: featuredPlaylistsQuery.hasNextPage,
|
|
||||||
onFetchMore: featuredPlaylistsQuery.fetchNext,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (auth != null || hasNewReleases)
|
|
||||||
HorizontalPlaybuttonCardView<Album>(
|
|
||||||
items: albums,
|
|
||||||
title: Text(context.l10n.new_releases),
|
|
||||||
isLoadingNextPage: newReleases.isLoadingNextPage,
|
|
||||||
hasNextPage: newReleases.hasNextPage,
|
|
||||||
onFetchMore: newReleases.fetchNext,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
SliverSafeArea(
|
|
||||||
sliver: SliverList.builder(
|
|
||||||
itemCount: madeForUser.data?["content"]?["items"]?.length ?? 0,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final item = madeForUser.data?["content"]?["items"]?[index];
|
|
||||||
final playlists = item["content"]?["items"]
|
|
||||||
?.where((itemL2) => itemL2["type"] == "playlist")
|
|
||||||
.map((itemL2) => PlaylistSimple.fromJson(itemL2))
|
|
||||||
.toList()
|
|
||||||
.cast<PlaylistSimple>() ??
|
|
||||||
<PlaylistSimple>[];
|
|
||||||
if (playlists.isEmpty) return const SizedBox.shrink();
|
|
||||||
return HorizontalPlaybuttonCardView<PlaylistSimple>(
|
|
||||||
items: playlists,
|
|
||||||
title: Text(item["name"] ?? ""),
|
|
||||||
hasNextPage: false,
|
|
||||||
isLoadingNextPage: false,
|
|
||||||
onFetchMore: () {},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -5,12 +5,35 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
import 'package:spotube/hooks/spotify/use_spotify_infinite_query.dart';
|
import 'package:spotube/hooks/spotify/use_spotify_infinite_query.dart';
|
||||||
|
import 'package:spotube/hooks/spotify/use_spotify_query.dart';
|
||||||
import 'package:spotube/provider/custom_spotify_endpoint_provider.dart';
|
import 'package:spotube/provider/custom_spotify_endpoint_provider.dart';
|
||||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||||
|
|
||||||
class CategoryQueries {
|
class CategoryQueries {
|
||||||
const CategoryQueries();
|
const CategoryQueries();
|
||||||
|
|
||||||
|
Query<List<Category>, dynamic> listAll(
|
||||||
|
WidgetRef ref, Market recommendationMarket) {
|
||||||
|
ref.watch(userPreferencesProvider.select((s) => s.locale));
|
||||||
|
final locale = useContext().l10n.localeName;
|
||||||
|
final query = useSpotifyQuery<List<Category>, dynamic>(
|
||||||
|
"category-playlists",
|
||||||
|
(spotify) async {
|
||||||
|
final categories = await spotify.categories
|
||||||
|
.list(
|
||||||
|
country: recommendationMarket,
|
||||||
|
locale: locale,
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
return categories.toList();
|
||||||
|
},
|
||||||
|
ref: ref,
|
||||||
|
);
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
InfiniteQuery<Page<Category>, dynamic, int> list(
|
InfiniteQuery<Page<Category>, dynamic, int> list(
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
Market recommendationMarket,
|
Market recommendationMarket,
|
||||||
|
Loading…
Reference in New Issue
Block a user