From 2daea2b3efb9b273eb6843c2106fd08d142edc38 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 10 Jan 2025 19:51:21 +0600 Subject: [PATCH] chore: make genre carousel items better in light mode --- lib/modules/home/sections/genres.dart | 334 ------------------ .../home/sections/genres/genre_card.dart | 114 ++++++ .../genres/genre_card_playlist_card.dart | 88 +++++ lib/modules/home/sections/genres/genres.dart | 180 ++++++++++ .../player/player_overlay_collapsed.dart | 1 - lib/modules/root/spotube_navigation_bar.dart | 41 ++- lib/pages/home/home.dart | 2 +- 7 files changed, 406 insertions(+), 354 deletions(-) delete mode 100644 lib/modules/home/sections/genres.dart create mode 100644 lib/modules/home/sections/genres/genre_card.dart create mode 100644 lib/modules/home/sections/genres/genre_card_playlist_card.dart create mode 100644 lib/modules/home/sections/genres/genres.dart diff --git a/lib/modules/home/sections/genres.dart b/lib/modules/home/sections/genres.dart deleted file mode 100644 index b273b970..00000000 --- a/lib/modules/home/sections/genres.dart +++ /dev/null @@ -1,334 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/fake.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/extensions/image.dart'; -import 'package:spotube/extensions/string.dart'; -import 'package:spotube/pages/home/genres/genre_playlists.dart'; -import 'package:spotube/pages/home/genres/genres.dart'; -import 'package:spotube/pages/playlist/playlist.dart'; -import 'package:spotube/provider/spotify/spotify.dart'; - -class HomeGenresSection extends HookConsumerWidget { - const HomeGenresSection({super.key}); - - @override - Widget build(BuildContext context, ref) { - final theme = context.theme; - final mediaQuery = MediaQuery.sizeOf(context); - - final categoriesQuery = ref.watch(categoriesProvider); - final categories = useMemoized( - () => - categoriesQuery.asData?.value - .where((c) => (c.icons?.length ?? 0) > 0) - .take(6) - .toList() ?? - [ - FakeData.category, - ], - [categoriesQuery.asData?.value], - ); - final controller = useMemoized(() => CarouselController(), []); - final interactedRef = useRef(false); - - useEffect(() { - int times = 0; - final timer = Timer.periodic( - const Duration(seconds: 5), - (timer) { - if (times > 5 || interactedRef.value) { - timer.cancel(); - return; - } - controller.animateNext( - const Duration(seconds: 2), - ); - times++; - }, - ); - - return () { - timer.cancel(); - controller.dispose(); - }; - }, []); - - return SliverList.list( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - context.l10n.genres, - style: context.theme.typography.h4, - ), - Directionality( - textDirection: TextDirection.rtl, - child: Button.link( - onPressed: () { - context.pushNamed(GenrePage.name); - }, - leading: const Icon(SpotubeIcons.angleRight), - child: Text( - context.l10n.browse_all, - ).muted(), - ), - ), - ], - ), - ), - const Gap(8), - Stack( - children: [ - SizedBox( - height: 280 * theme.scaling, - child: Carousel( - controller: controller, - transition: const CarouselTransition.sliding(gap: 24), - sizeConstraint: CarouselSizeConstraint.fixed( - mediaQuery.mdAndUp - ? mediaQuery.width * .6 - : mediaQuery.width * .95, - ), - itemCount: categories.length, - pauseOnHover: true, - direction: Axis.horizontal, - itemBuilder: (context, index) { - final category = categories[index]; - final playlists = - ref.watch(categoryPlaylistsProvider(category.id!)); - final playlistsData = playlists.asData?.value.items.take(8) ?? - List.generate(5, (index) => FakeData.playlistSimple); - - return Container( - margin: const EdgeInsets.symmetric(horizontal: 8), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - borderRadius: theme.borderRadiusXxl, - border: Border.all( - color: theme.colorScheme.border, - width: 1, - ), - image: DecorationImage( - image: UniversalImage.imageProvider( - category.icons!.first.url!, - ), - colorFilter: ColorFilter.mode( - theme.colorScheme.background.withAlpha(125), - BlendMode.darken, - ), - fit: BoxFit.cover, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 16, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - category.name!, - style: const TextStyle(color: Colors.white), - ).h3(), - Button.link( - onPressed: () { - context.pushNamed( - GenrePlaylistsPage.name, - pathParameters: {'categoryId': category.id!}, - extra: category, - ); - }, - child: Text( - context.l10n.view_all, - style: const TextStyle(color: Colors.white), - ).muted(), - ), - ], - ), - Expanded( - child: Skeleton.ignore( - child: Skeletonizer( - enabled: playlists.isLoading, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: playlistsData.length, - separatorBuilder: (context, index) => - const Gap(12), - itemBuilder: (context, index) { - final playlist = - playlistsData.elementAt(index); - - return Container( - width: 115 * theme.scaling, - decoration: BoxDecoration( - color: theme.colorScheme.background - .withAlpha(75), - borderRadius: theme.borderRadiusMd, - ), - child: SurfaceBlur( - borderRadius: theme.borderRadiusMd, - surfaceBlur: theme.surfaceBlur, - child: Button( - style: - ButtonVariance.secondary.copyWith( - padding: (context, states, value) => - const EdgeInsets.all(8), - decoration: (context, states, value) { - final decoration = ButtonVariance - .secondary - .decoration(context, states) - as BoxDecoration; - - if (states.isNotEmpty) { - return decoration; - } - - return decoration.copyWith( - color: decoration.color - ?.withAlpha(180), - ); - }, - ), - onPressed: () { - context.pushNamed( - PlaylistPage.name, - pathParameters: { - "id": playlist.id!, - }, - extra: playlist, - ); - }, - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - spacing: 5, - children: [ - ClipRRect( - borderRadius: - theme.borderRadiusSm, - child: UniversalImage( - path: (playlist.images)! - .asUrlString( - placeholder: ImagePlaceholder - .collection, - index: 1, - ), - fit: BoxFit.cover, - height: 100 * theme.scaling, - width: 100 * theme.scaling, - ), - ), - Text( - playlist.name!, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ).semiBold().small(), - if (playlist.description != null) - Text( - playlist.description - ?.unescapeHtml() - .cleanHtml() ?? - "", - maxLines: 2, - overflow: TextOverflow.ellipsis, - ).xSmall().muted(), - ], - ), - ), - ), - ); - }, - ), - ), - ), - ) - ], - ), - ); - }, - ), - ), - Positioned( - left: 0, - child: Container( - height: 280 * theme.scaling, - width: (mediaQuery.mdAndUp ? 80 : 50) * theme.scaling, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - theme.colorScheme.background.withAlpha(255), - theme.colorScheme.background.withAlpha(0), - ], - ), - ), - alignment: Alignment.center, - child: IconButton.ghost( - size: - mediaQuery.mdAndUp ? ButtonSize.normal : ButtonSize.small, - icon: const Icon(SpotubeIcons.angleLeft), - onPressed: () { - controller.animatePrevious( - const Duration(seconds: 1), - ); - interactedRef.value = true; - }, - ), - ), - ), - Positioned( - right: 0, - child: Container( - height: 280 * theme.scaling, - width: (mediaQuery.mdAndUp ? 80 : 50) * theme.scaling, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - theme.colorScheme.background.withAlpha(0), - theme.colorScheme.background.withAlpha(255), - ], - ), - ), - alignment: Alignment.center, - child: IconButton.ghost( - size: - mediaQuery.mdAndUp ? ButtonSize.normal : ButtonSize.small, - icon: const Icon(SpotubeIcons.angleRight), - onPressed: () { - controller.animateNext( - const Duration(seconds: 1), - ); - interactedRef.value = true; - }, - ), - ), - ), - ], - ), - const Gap(8), - Center( - child: CarouselDotIndicator( - itemCount: categories.length, - controller: controller, - ), - ), - ], - ); - } -} diff --git a/lib/modules/home/sections/genres/genre_card.dart b/lib/modules/home/sections/genres/genre_card.dart new file mode 100644 index 00000000..722e6644 --- /dev/null +++ b/lib/modules/home/sections/genres/genre_card.dart @@ -0,0 +1,114 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:spotify/spotify.dart' hide Offset; +import 'package:spotube/collections/fake.dart'; +import 'package:spotube/collections/gradients.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/modules/home/sections/genres/genre_card_playlist_card.dart'; +import 'package:spotube/pages/home/genres/genre_playlists.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; + +final random = Random(); +final gradientState = StateProvider.family( + (ref, String id) => gradients[random.nextInt(gradients.length)], +); + +class GenreSectionCard extends HookConsumerWidget { + final Category category; + const GenreSectionCard({ + super.key, + required this.category, + }); + + @override + Widget build(BuildContext context, ref) { + final theme = Theme.of(context); + final playlists = category == FakeData.category + ? null + : ref.watch(categoryPlaylistsProvider(category.id!)); + final playlistsData = playlists?.asData?.value.items.take(8) ?? + List.generate(5, (index) => FakeData.playlistSimple); + + final randomGradient = ref.watch(gradientState(category.id!)); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + borderRadius: theme.borderRadiusXxl, + boxShadow: [ + BoxShadow( + color: theme.colorScheme.foreground, + offset: const Offset(0, 5), + blurRadius: 7, + spreadRadius: -5, + ), + ], + image: DecorationImage( + image: UniversalImage.imageProvider( + category.icons!.first.url!, + ), + fit: BoxFit.cover, + ), + ), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: theme.borderRadiusXxl, + gradient: randomGradient + .withOpacity(theme.brightness == Brightness.dark ? 0.2 : 0.7), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + category.name!, + style: const TextStyle(color: Colors.white), + ).h3(), + Button.link( + onPressed: () { + context.pushNamed( + GenrePlaylistsPage.name, + pathParameters: {'categoryId': category.id!}, + extra: category, + ); + }, + child: Text( + context.l10n.view_all, + style: const TextStyle(color: Colors.white), + ).muted(), + ), + ], + ), + Expanded( + child: Skeleton.ignore( + child: Skeletonizer( + enabled: playlists?.isLoading ?? false, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: playlistsData.length, + separatorBuilder: (context, index) => const Gap(12), + itemBuilder: (context, index) { + final playlist = playlistsData.elementAt(index); + + return GenreSectionCardPlaylistCard(playlist: playlist); + }, + ), + ), + ), + ) + ], + ), + ), + ); + } +} diff --git a/lib/modules/home/sections/genres/genre_card_playlist_card.dart b/lib/modules/home/sections/genres/genre_card_playlist_card.dart new file mode 100644 index 00000000..bbc42c61 --- /dev/null +++ b/lib/modules/home/sections/genres/genre_card_playlist_card.dart @@ -0,0 +1,88 @@ +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/extensions/string.dart'; +import 'package:spotube/pages/playlist/playlist.dart'; + +class GenreSectionCardPlaylistCard extends HookConsumerWidget { + final PlaylistSimple playlist; + const GenreSectionCardPlaylistCard({ + super.key, + required this.playlist, + }); + + @override + Widget build(BuildContext context, ref) { + final theme = Theme.of(context); + + return Container( + width: 115 * theme.scaling, + decoration: BoxDecoration( + color: theme.colorScheme.background.withAlpha(75), + borderRadius: theme.borderRadiusMd, + ), + child: SurfaceBlur( + borderRadius: theme.borderRadiusMd, + surfaceBlur: theme.surfaceBlur, + child: Button( + style: ButtonVariance.secondary.copyWith( + padding: (context, states, value) => const EdgeInsets.all(8), + decoration: (context, states, value) { + final decoration = ButtonVariance.secondary + .decoration(context, states) as BoxDecoration; + + if (states.isNotEmpty) { + return decoration; + } + + return decoration.copyWith( + color: decoration.color?.withAlpha(180), + ); + }, + ), + onPressed: () { + context.pushNamed( + PlaylistPage.name, + pathParameters: { + "id": playlist.id!, + }, + extra: playlist, + ); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 5, + children: [ + ClipRRect( + borderRadius: theme.borderRadiusSm, + child: UniversalImage( + path: (playlist.images)!.asUrlString( + placeholder: ImagePlaceholder.collection, + index: 1, + ), + fit: BoxFit.cover, + height: 100 * theme.scaling, + width: 100 * theme.scaling, + ), + ), + Text( + playlist.name!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ).semiBold().small(), + if (playlist.description != null) + Text( + playlist.description?.unescapeHtml().cleanHtml() ?? "", + maxLines: 2, + overflow: TextOverflow.ellipsis, + ).xSmall().muted(), + ], + ), + ), + ), + ); + } +} diff --git a/lib/modules/home/sections/genres/genres.dart b/lib/modules/home/sections/genres/genres.dart new file mode 100644 index 00000000..5e6fa330 --- /dev/null +++ b/lib/modules/home/sections/genres/genres.dart @@ -0,0 +1,180 @@ +import 'dart:async'; + +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:spotube/collections/fake.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/modules/home/sections/genres/genre_card.dart'; +import 'package:spotube/pages/home/genres/genres.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; + +class HomeGenresSection extends HookConsumerWidget { + const HomeGenresSection({super.key}); + + @override + Widget build(BuildContext context, ref) { + final theme = context.theme; + final mediaQuery = MediaQuery.sizeOf(context); + + final categoriesQuery = ref.watch(categoriesProvider); + final categories = useMemoized( + () => + categoriesQuery.asData?.value + .where((c) => (c.icons?.length ?? 0) > 0) + .take(6) + .toList() ?? + [ + FakeData.category, + ], + [categoriesQuery.asData?.value], + ); + final controller = useMemoized(() => CarouselController(), []); + final interactedRef = useRef(false); + + useEffect(() { + int times = 0; + final timer = Timer.periodic( + const Duration(seconds: 5), + (timer) { + if (times > 5 || interactedRef.value) { + timer.cancel(); + return; + } + controller.animateNext( + const Duration(seconds: 2), + ); + times++; + }, + ); + + return () { + timer.cancel(); + }; + }, []); + + return SliverList.list( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + context.l10n.genres, + style: context.theme.typography.h4, + ), + Button.link( + onPressed: () { + context.pushNamed(GenrePage.name); + }, + trailing: const Icon(SpotubeIcons.angleRight), + child: Text( + context.l10n.browse_all, + ).muted(), + ), + ], + ), + ), + const Gap(8), + Stack( + children: [ + SizedBox( + height: 280 * theme.scaling, + child: Carousel( + controller: controller, + transition: const CarouselTransition.sliding(gap: 24), + sizeConstraint: CarouselSizeConstraint.fixed( + mediaQuery.mdAndUp + ? mediaQuery.width * .6 + : mediaQuery.width * .95, + ), + itemCount: categories.length, + pauseOnHover: true, + direction: Axis.horizontal, + itemBuilder: (context, index) { + final category = categories[index]; + + return Skeletonizer( + enabled: categoriesQuery.isLoading, + child: GenreSectionCard(category: category), + ); + }, + ), + ), + Positioned( + left: 0, + child: Container( + height: 280 * theme.scaling, + width: (mediaQuery.mdAndUp ? 80 : 50) * theme.scaling, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + theme.colorScheme.background.withAlpha(255), + theme.colorScheme.background.withAlpha(0), + ], + ), + ), + alignment: Alignment.center, + child: IconButton.ghost( + size: + mediaQuery.mdAndUp ? ButtonSize.normal : ButtonSize.small, + icon: const Icon(SpotubeIcons.angleLeft), + onPressed: () { + controller.animatePrevious( + const Duration(seconds: 1), + ); + interactedRef.value = true; + }, + ), + ), + ), + Positioned( + right: 0, + child: Container( + height: 280 * theme.scaling, + width: (mediaQuery.mdAndUp ? 80 : 50) * theme.scaling, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + theme.colorScheme.background.withAlpha(0), + theme.colorScheme.background.withAlpha(255), + ], + ), + ), + alignment: Alignment.center, + child: IconButton.ghost( + size: + mediaQuery.mdAndUp ? ButtonSize.normal : ButtonSize.small, + icon: const Icon(SpotubeIcons.angleRight), + onPressed: () { + controller.animateNext( + const Duration(seconds: 1), + ); + interactedRef.value = true; + }, + ), + ), + ), + ], + ), + const Gap(8), + Center( + child: CarouselDotIndicator( + itemCount: categories.length, + controller: controller, + ), + ), + ], + ); + } +} diff --git a/lib/modules/player/player_overlay_collapsed.dart b/lib/modules/player/player_overlay_collapsed.dart index 52847c40..d0961ade 100644 --- a/lib/modules/player/player_overlay_collapsed.dart +++ b/lib/modules/player/player_overlay_collapsed.dart @@ -40,7 +40,6 @@ class PlayerOverlayCollapsedSection extends HookConsumerWidget { ? Padding( padding: const EdgeInsets.all(5), child: SurfaceCard( - borderWidth: 0, surfaceBlur: theme.surfaceBlur, surfaceOpacity: theme.surfaceOpacity, padding: EdgeInsets.zero, diff --git a/lib/modules/root/spotube_navigation_bar.dart b/lib/modules/root/spotube_navigation_bar.dart index 8d3e71f4..c19b3a40 100644 --- a/lib/modules/root/spotube_navigation_bar.dart +++ b/lib/modules/root/spotube_navigation_bar.dart @@ -55,25 +55,30 @@ class SpotubeNavigationBar extends HookConsumerWidget { duration: const Duration(milliseconds: 100), height: panelHeight, child: SingleChildScrollView( - child: NavigationBar( - index: selectedIndex, - surfaceBlur: context.theme.surfaceBlur, - surfaceOpacity: context.theme.surfaceOpacity, - onSelected: (i) { - ServiceUtils.navigateNamed(context, navbarTileList[i].name); - }, + child: Column( children: [ - for (final tile in navbarTileList) - NavigationButton( - style: const ButtonStyle.muted(density: ButtonDensity.icon), - selectedStyle: - const ButtonStyle.fixed(density: ButtonDensity.icon), - child: Badge( - isLabelVisible: tile.id == "library" && downloadCount > 0, - label: Text(downloadCount.toString()), - child: Icon(tile.icon), - ), - ) + const Divider(), + NavigationBar( + index: selectedIndex, + surfaceBlur: context.theme.surfaceBlur, + surfaceOpacity: context.theme.surfaceOpacity, + onSelected: (i) { + ServiceUtils.navigateNamed(context, navbarTileList[i].name); + }, + children: [ + for (final tile in navbarTileList) + NavigationButton( + style: const ButtonStyle.muted(density: ButtonDensity.icon), + selectedStyle: + const ButtonStyle.fixed(density: ButtonDensity.icon), + child: Badge( + isLabelVisible: tile.id == "library" && downloadCount > 0, + label: Text(downloadCount.toString()), + child: Icon(tile.icon), + ), + ) + ], + ), ], ), ), diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index 2dfbc0f3..ea0a651b 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -10,7 +10,7 @@ import 'package:spotube/modules/connect/connect_device.dart'; import 'package:spotube/modules/home/sections/featured.dart'; import 'package:spotube/modules/home/sections/feed.dart'; import 'package:spotube/modules/home/sections/friends.dart'; -import 'package:spotube/modules/home/sections/genres.dart'; +import 'package:spotube/modules/home/sections/genres/genres.dart'; import 'package:spotube/modules/home/sections/made_for_user.dart'; import 'package:spotube/modules/home/sections/new_releases.dart'; import 'package:spotube/modules/home/sections/recent.dart';