chore: make genre carousel items better in light mode

This commit is contained in:
Kingkor Roy Tirtho 2025-01-10 19:51:21 +06:00
parent 88906098dd
commit 2daea2b3ef
7 changed files with 406 additions and 354 deletions

View File

@ -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,
),
),
],
);
}
}

View File

@ -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);
},
),
),
),
)
],
),
),
);
}
}

View File

@ -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(),
],
),
),
),
);
}
}

View File

@ -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,
),
),
],
);
}
}

View File

@ -40,7 +40,6 @@ class PlayerOverlayCollapsedSection extends HookConsumerWidget {
? Padding( ? Padding(
padding: const EdgeInsets.all(5), padding: const EdgeInsets.all(5),
child: SurfaceCard( child: SurfaceCard(
borderWidth: 0,
surfaceBlur: theme.surfaceBlur, surfaceBlur: theme.surfaceBlur,
surfaceOpacity: theme.surfaceOpacity, surfaceOpacity: theme.surfaceOpacity,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,

View File

@ -55,25 +55,30 @@ class SpotubeNavigationBar extends HookConsumerWidget {
duration: const Duration(milliseconds: 100), duration: const Duration(milliseconds: 100),
height: panelHeight, height: panelHeight,
child: SingleChildScrollView( child: SingleChildScrollView(
child: NavigationBar( child: Column(
index: selectedIndex,
surfaceBlur: context.theme.surfaceBlur,
surfaceOpacity: context.theme.surfaceOpacity,
onSelected: (i) {
ServiceUtils.navigateNamed(context, navbarTileList[i].name);
},
children: [ children: [
for (final tile in navbarTileList) const Divider(),
NavigationButton( NavigationBar(
style: const ButtonStyle.muted(density: ButtonDensity.icon), index: selectedIndex,
selectedStyle: surfaceBlur: context.theme.surfaceBlur,
const ButtonStyle.fixed(density: ButtonDensity.icon), surfaceOpacity: context.theme.surfaceOpacity,
child: Badge( onSelected: (i) {
isLabelVisible: tile.id == "library" && downloadCount > 0, ServiceUtils.navigateNamed(context, navbarTileList[i].name);
label: Text(downloadCount.toString()), },
child: Icon(tile.icon), 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),
),
)
],
),
], ],
), ),
), ),

View File

@ -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/featured.dart';
import 'package:spotube/modules/home/sections/feed.dart'; import 'package:spotube/modules/home/sections/feed.dart';
import 'package:spotube/modules/home/sections/friends.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/made_for_user.dart';
import 'package:spotube/modules/home/sections/new_releases.dart'; import 'package:spotube/modules/home/sections/new_releases.dart';
import 'package:spotube/modules/home/sections/recent.dart'; import 'package:spotube/modules/home/sections/recent.dart';