refactor: add genre carousel buttons and indicators

This commit is contained in:
Kingkor Roy Tirtho 2025-01-06 21:33:26 +06:00
parent bf94a490bb
commit 46852545a9
2 changed files with 274 additions and 185 deletions

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -36,209 +38,297 @@ class HomeGenresSection extends HookConsumerWidget {
], ],
[mediaQuery.mdAndDown, categoriesQuery.asData?.value], [mediaQuery.mdAndDown, categoriesQuery.asData?.value],
); );
final controller = useMemoized(() => CarouselController(), []);
final interactedRef = useRef(false);
return SliverMainAxisGroup( useEffect(() {
slivers: [ int times = 0;
SliverToBoxAdapter( Timer.periodic(
child: Padding( const Duration(seconds: 5),
padding: const EdgeInsets.symmetric(horizontal: 8), (timer) {
child: Row( if (times > 5 || interactedRef.value) {
mainAxisAlignment: MainAxisAlignment.spaceBetween, timer.cancel();
children: [ return;
Text( }
context.l10n.genres, controller.animateNext(
style: context.theme.typography.h4, const Duration(seconds: 2),
);
times++;
},
);
return 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(),
), ),
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 SliverGap(8), const Gap(8),
SliverToBoxAdapter( Stack(
child: SizedBox( children: [
height: 280 * theme.scaling, SizedBox(
child: Carousel( height: 280 * theme.scaling,
transition: const CarouselTransition.sliding(gap: 24), child: Carousel(
sizeConstraint: CarouselSizeConstraint.fixed( controller: controller,
mediaQuery.mdAndUp transition: const CarouselTransition.sliding(gap: 24),
? mediaQuery.width * .6 sizeConstraint: CarouselSizeConstraint.fixed(
: mediaQuery.width * .95, mediaQuery.mdAndUp
), ? mediaQuery.width * .6
itemCount: categories.length, : mediaQuery.width * .95,
autoplaySpeed: const Duration(seconds: 2), ),
duration: const Duration(seconds: 5), itemCount: categories.length,
pauseOnHover: true, pauseOnHover: true,
direction: Axis.horizontal, direction: Axis.horizontal,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final category = categories[index]; final category = categories[index];
final playlists = final playlists =
ref.watch(categoryPlaylistsProvider(category.id!)); ref.watch(categoryPlaylistsProvider(category.id!));
final playlistsData = playlists.asData?.value.items.take(8) ?? final playlistsData = playlists.asData?.value.items.take(8) ??
List.generate(5, (index) => FakeData.playlistSimple); List.generate(5, (index) => FakeData.playlistSimple);
return Container( return Container(
margin: const EdgeInsets.symmetric(horizontal: 8), margin: const EdgeInsets.symmetric(horizontal: 8),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: theme.borderRadiusXxl, borderRadius: theme.borderRadiusXxl,
border: Border.all( border: Border.all(
color: theme.colorScheme.border, color: theme.colorScheme.border,
width: 1, width: 1,
),
image: DecorationImage(
image: UniversalImage.imageProvider(
category.icons!.first.url!,
), ),
colorFilter: ColorFilter.mode( image: DecorationImage(
theme.colorScheme.background.withAlpha(125), image: UniversalImage.imageProvider(
BlendMode.darken, category.icons!.first.url!,
),
colorFilter: ColorFilter.mode(
theme.colorScheme.background.withAlpha(125),
BlendMode.darken,
),
fit: BoxFit.cover,
), ),
fit: BoxFit.cover,
), ),
), child: Column(
child: Column( crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, spacing: 16,
spacing: 16, children: [
children: [ Row(
Row( mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
children: [ Text(
Text( category.name!,
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), style: const TextStyle(color: Colors.white),
).muted(), ).h3(),
), Button.link(
], onPressed: () {
), context.pushNamed(
Expanded( GenrePlaylistsPage.name,
child: Skeleton.ignore( pathParameters: {'categoryId': category.id!},
child: Skeletonizer( extra: category,
enabled: playlists.isLoading, );
child: SingleChildScrollView( },
scrollDirection: Axis.horizontal, child: Text(
child: Row( context.l10n.view_all,
spacing: 12, style: const TextStyle(color: Colors.white),
mainAxisAlignment: MainAxisAlignment.center, ).muted(),
children: [ ),
for (final playlist in playlistsData) ],
Container( ),
width: 115 * theme.scaling, Expanded(
decoration: BoxDecoration( child: Skeleton.ignore(
color: theme.colorScheme.background child: Skeletonizer(
.withAlpha(75), enabled: playlists.isLoading,
borderRadius: theme.borderRadiusMd, child: SingleChildScrollView(
), scrollDirection: Axis.horizontal,
child: SurfaceBlur( child: Row(
borderRadius: theme.borderRadiusMd, spacing: 12,
surfaceBlur: theme.surfaceBlur, mainAxisAlignment: MainAxisAlignment.center,
child: Button( children: [
style: for (final playlist in playlistsData)
ButtonVariance.secondary.copyWith( Container(
padding: (context, states, value) => width: 115 * theme.scaling,
const EdgeInsets.all(8), decoration: BoxDecoration(
decoration: color: theme.colorScheme.background
(context, states, value) { .withAlpha(75),
final decoration = ButtonVariance borderRadius: theme.borderRadiusMd,
.secondary ),
.decoration( child: SurfaceBlur(
context, states) borderRadius: theme.borderRadiusMd,
as BoxDecoration; 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) { if (states.isNotEmpty) {
return decoration; return decoration;
} }
return decoration.copyWith( return decoration.copyWith(
color: decoration.color color: decoration.color
?.withAlpha(180), ?.withAlpha(180),
);
},
),
onPressed: () {
context.pushNamed(
PlaylistPage.name,
pathParameters: {
"id": playlist.id!,
},
extra: playlist,
); );
}, },
), child: Column(
onPressed: () { crossAxisAlignment:
context.pushNamed( CrossAxisAlignment.start,
PlaylistPage.name, spacing: 5,
pathParameters: { children: [
"id": playlist.id!, ClipRRect(
}, borderRadius:
extra: playlist, theme.borderRadiusSm,
); child: UniversalImage(
}, path: (playlist.images)!
child: Column( .asUrlString(
crossAxisAlignment: placeholder:
CrossAxisAlignment.start, ImagePlaceholder
spacing: 5, .collection,
children: [ index: 1,
ClipRRect( ),
borderRadius: fit: BoxFit.cover,
theme.borderRadiusSm, height: 100 * theme.scaling,
child: UniversalImage( width: 100 * theme.scaling,
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( Text(
playlist.description playlist.name!,
?.unescapeHtml()
.cleanHtml() ??
"",
maxLines: 2, maxLines: 2,
overflow: overflow:
TextOverflow.ellipsis, TextOverflow.ellipsis,
).xSmall().muted(), ).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

@ -1,10 +1,9 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:path/path.dart'; import 'package:path/path.dart' as path;
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/logger/logger.dart';
import 'package:spotube/services/sourced_track/enums.dart'; import 'package:spotube/services/sourced_track/enums.dart';
@ -22,7 +21,7 @@ class LocalFolderCacheExportDialog extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final ThemeData(:textTheme, :colorScheme) = Theme.of(context); final ThemeData(:typography, :colorScheme) = Theme.of(context);
final files = useState<List<File>>([]); final files = useState<List<File>>([]);
final filesExported = useState<int>(0); final filesExported = useState<int>(0);
@ -31,7 +30,7 @@ class LocalFolderCacheExportDialog extends HookConsumerWidget {
final stream = cacheDir.list().where( final stream = cacheDir.list().where(
(event) => (event) =>
event is File && event is File &&
codecs.contains(extension(event.path).replaceAll(".", "")), codecs.contains(path.extension(event.path).replaceAll(".", "")),
); );
stream.listen( stream.listen(
@ -76,8 +75,8 @@ class LocalFolderCacheExportDialog extends HookConsumerWidget {
), ),
TextSpan( TextSpan(
text: "\n${exportDir.path}?", text: "\n${exportDir.path}?",
style: textTheme.labelMedium!.copyWith( style: typography.small.copyWith(
color: colorScheme.secondary, color: colorScheme.mutedForeground,
), ),
), ),
], ],
@ -102,7 +101,7 @@ class LocalFolderCacheExportDialog extends HookConsumerWidget {
), ),
), ),
actions: [ actions: [
TextButton( Button.outline(
onPressed: isExportInProgress onPressed: isExportInProgress
? null ? null
: () { : () {
@ -110,14 +109,14 @@ class LocalFolderCacheExportDialog extends HookConsumerWidget {
}, },
child: Text(context.l10n.cancel), child: Text(context.l10n.cancel),
), ),
TextButton( Button.primary(
onPressed: isExportInProgress onPressed: isExportInProgress
? null ? null
: () async { : () async {
for (final file in files.value) { for (final file in files.value) {
try { try {
final destinationFile = File( final destinationFile = File(
join(exportDir.path, basename(file.path)), path.join(exportDir.path, path.basename(file.path)),
); );
if (await destinationFile.exists()) { if (await destinationFile.exists()) {