refactor: use shadcn for TrackTile

This commit is contained in:
Kingkor Roy Tirtho 2025-01-08 22:16:46 +06:00
parent e54a646073
commit 88906098dd
6 changed files with 309 additions and 268 deletions

View File

@ -1,4 +1,3 @@
import 'package:flutter/material.dart' show ListTile;
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
@ -21,9 +20,6 @@ class TrackPresentation extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final headerTextStyle = context.theme.typography.small.copyWith(
color: context.theme.colorScheme.mutedForeground,
);
final scrollController = useScrollController(); final scrollController = useScrollController();
final focusNode = useFocusNode(); final focusNode = useFocusNode();
final scale = context.theme.scaling; final scale = context.theme.scaling;
@ -66,10 +62,11 @@ class TrackPresentation extends HookConsumerWidget {
TrackPresentationModifiersSection( TrackPresentationModifiersSection(
focusNode: focusNode, focusNode: focusNode,
), ),
ListTile( Basic(
titleTextStyle: headerTextStyle, padding: const EdgeInsets.symmetric(
subtitleTextStyle: headerTextStyle, vertical: 8,
leadingAndTrailingTextStyle: headerTextStyle, horizontal: 16,
),
leading: constrains.mdAndUp ? const Text(" #") : null, leading: constrains.mdAndUp ? const Text(" #") : null,
title: Row( title: Row(
children: [ children: [
@ -85,7 +82,7 @@ class TrackPresentation extends HookConsumerWidget {
Text(context.l10n.duration), Text(context.l10n.duration),
], ],
), ),
), ).small().muted(),
], ],
); );
}, },

View File

@ -32,7 +32,11 @@ Future<void> Function(Track track, int index) useTrackTilePlayCallback(
ref.read(presentationStateProvider(options.collection).notifier); ref.read(presentationStateProvider(options.collection).notifier);
if (state.selectedTracks.isNotEmpty) { if (state.selectedTracks.isNotEmpty) {
notifier.selectTrack(track); if (state.selectedTracks.contains(track)) {
notifier.deselectTrack(track);
} else {
notifier.selectTrack(track);
}
return; return;
} }

View File

@ -1,8 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart' show ListTile, Material, MaterialType;
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
@ -14,7 +14,9 @@ import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/components/links/artist_link.dart'; import 'package:spotube/components/links/artist_link.dart';
import 'package:spotube/components/links/link_text.dart'; import 'package:spotube/components/links/link_text.dart';
import 'package:spotube/components/track_tile/track_options.dart'; import 'package:spotube/components/track_tile/track_options.dart';
import 'package:spotube/components/ui/button_tile.dart';
import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/button_variance.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/duration.dart';
import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/image.dart';
@ -89,225 +91,232 @@ class TrackTile extends HookConsumerWidget {
}, },
child: HoverBuilder( child: HoverBuilder(
permanentState: isSelected || constrains.smAndDown ? true : null, permanentState: isSelected || constrains.smAndDown ? true : null,
builder: (context, isHovering) => Material( builder: (context, isHovering) => ButtonTile(
type: MaterialType.transparency, selected: isSelected,
child: ListTile( onPressed: () async {
selectedColor: theme.colorScheme.primary, if (isBlackListed) return;
selectedTileColor: theme.colorScheme.primary.withOpacity(0.1), try {
selected: isSelected, isLoading.value = true;
onTap: () async { await onTap?.call();
try { } finally {
isLoading.value = true; if (context.mounted) {
await onTap?.call(); isLoading.value = false;
} finally {
if (context.mounted) {
isLoading.value = false;
}
} }
}, }
onLongPress: onLongPress, },
enabled: !isBlackListed, onLongPress: onLongPress,
contentPadding: EdgeInsets.zero, style: (isBlackListed
tileColor: isBlackListed ? theme.colorScheme.destructive : null, ? ButtonVariance.destructive
horizontalTitleGap: 12, : ButtonVariance.ghost)
leadingAndTrailingTextStyle: theme.typography.normal.copyWith( .copyWith(
color: theme.colorScheme.foreground, padding: (context, states) =>
), const EdgeInsets.symmetric(vertical: 8, horizontal: 0),
titleTextStyle: theme.typography.normal.copyWith( ),
color: theme.colorScheme.foreground, leading: Row(
), mainAxisSize: MainAxisSize.min,
subtitleTextStyle: theme.typography.xSmall.copyWith( children: [
color: theme.colorScheme.mutedForeground, ...?leadingActions,
), AnimatedCrossFade(
leading: Row( duration: const Duration(milliseconds: 300),
mainAxisSize: MainAxisSize.min, crossFadeState: index != null && onChanged == null
children: [ ? CrossFadeState.showSecond
...?leadingActions, : CrossFadeState.showFirst,
AnimatedCrossFade( firstChild: Checkbox(
duration: const Duration(milliseconds: 300), state: selected
crossFadeState: index != null && onChanged == null ? CheckboxState.checked
? CrossFadeState.showSecond : CheckboxState.unchecked,
: CrossFadeState.showFirst, onChanged: (state) =>
firstChild: Checkbox( onChanged?.call(state == CheckboxState.checked),
state: selected ),
? CheckboxState.checked secondChild: constrains.smAndDown
: CheckboxState.unchecked, ? const SizedBox(width: 16)
onChanged: (state) => : SizedBox(
onChanged?.call(state == CheckboxState.checked), width: 50,
), child: Padding(
secondChild: constrains.smAndDown padding: const EdgeInsets.symmetric(horizontal: 6),
? const SizedBox(width: 16) child: Text(
: SizedBox( '${(index ?? 0) + 1}',
width: 50, maxLines: 1,
child: Padding( style: theme.typography.small,
padding: textAlign: TextAlign.center,
const EdgeInsets.symmetric(horizontal: 6),
child: Text(
'${(index ?? 0) + 1}',
maxLines: 1,
style: theme.typography.small,
textAlign: TextAlign.center,
),
), ),
), ),
), ),
Stack( ),
children: [ Stack(
Container( children: [
height: 40, Container(
width: 40, height: 40,
width: 40,
decoration: BoxDecoration(
borderRadius: theme.borderRadiusMd,
image: DecorationImage(
fit: BoxFit.cover,
image: UniversalImage.imageProvider(
(track.album?.images).asUrlString(
placeholder: ImagePlaceholder.albumArt,
),
),
),
),
),
Positioned.fill(
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: theme.borderRadiusMd, borderRadius: theme.borderRadiusMd,
image: DecorationImage( color: isHovering
fit: BoxFit.cover, ? Colors.black.withAlpha(102)
image: UniversalImage.imageProvider( : Colors.transparent,
(track.album?.images).asUrlString( ),
placeholder: ImagePlaceholder.albumArt, ),
),
Positioned.fill(
child: Center(
child: Skeleton.ignore(
child: Consumer(
builder: (context, ref, _) {
final isFetchingActiveTrack =
ref.watch(queryingTrackInfoProvider);
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: switch ((
isPlaying,
isFetchingActiveTrack,
isPlaying,
isHovering,
isLoading.value
)) {
(true, true, _, _, _) ||
(_, _, _, _, true) =>
const SizedBox(
width: 26,
height: 26,
child:
CircularProgressIndicator(size: 1.5),
),
(_, _, true, _, _) => Icon(
SpotubeIcons.pause,
color: theme.colorScheme.primary,
),
(_, _, _, true, _) => const Icon(
SpotubeIcons.play,
color: Colors.white,
),
_ => const SizedBox.shrink(),
},
);
},
),
),
),
),
],
),
],
),
title: Row(
children: [
Expanded(
flex: 6,
child: switch (track) {
LocalTrack() => Text(
track.name!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
_ => Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Button(
style: ButtonVariance.link.copyWith(
padding: (context, states) => EdgeInsets.zero,
), ),
), onPressed: () {
), context.pushNamed(
), TrackPage.name,
), pathParameters: {
Positioned.fill( "id": track.id!,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
decoration: BoxDecoration(
borderRadius: theme.borderRadiusMd,
color: isHovering
? Colors.black.withOpacity(0.4)
: Colors.transparent,
),
),
),
Positioned.fill(
child: Center(
child: Skeleton.ignore(
child: Consumer(
builder: (context, ref, _) {
final isFetchingActiveTrack =
ref.watch(queryingTrackInfoProvider);
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: switch ((
isPlaying,
isFetchingActiveTrack,
isPlaying,
isHovering,
isLoading.value
)) {
(true, true, _, _, _) ||
(_, _, _, _, true) =>
const SizedBox(
width: 26,
height: 26,
child: CircularProgressIndicator(
size: 1.5),
),
(_, _, true, _, _) => Icon(
SpotubeIcons.pause,
color: theme.colorScheme.primary,
),
(_, _, _, true, _) => const Icon(
SpotubeIcons.play,
color: Colors.white,
),
_ => const SizedBox.shrink(),
}, },
); );
}, },
child: Text(
track.name!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
), ),
), ),
), ],
), ),
], },
), ),
], if (constrains.mdAndUp) ...[
), const SizedBox(width: 8),
title: Row(
children: [
Expanded( Expanded(
flex: 6, flex: 4,
child: switch (track) { child: switch (track) {
LocalTrack() => Text( LocalTrack() => Text(
track.name!, track.album!.name!,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
_ => LinkText( _ => Align(
track.name!, alignment: Alignment.centerLeft,
"/track/${track.id}", child: LinkText(
push: true,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
},
),
if (constrains.mdAndUp) ...[
const SizedBox(width: 8),
Expanded(
flex: 4,
child: switch (track) {
LocalTrack() => Text(
track.album!.name!, track.album!.name!,
maxLines: 1, "/album/${track.album?.id}",
extra: track.album,
push: true,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
_ => Align( )
alignment: Alignment.centerLeft, },
child: LinkText( ),
track.album!.name!,
"/album/${track.album?.id}",
extra: track.album,
push: true,
overflow: TextOverflow.ellipsis,
),
)
},
),
],
], ],
), ],
subtitle: Align( ),
alignment: Alignment.centerLeft, subtitle: Align(
child: track is LocalTrack alignment: Alignment.centerLeft,
? Text( child: track is LocalTrack
track.artists?.asString() ?? '', ? Text(
) track.artists?.asString() ?? '',
: ClipRect( )
child: ConstrainedBox( : ClipRect(
constraints: const BoxConstraints(maxHeight: 40), child: ConstrainedBox(
child: ArtistLink( constraints: const BoxConstraints(maxHeight: 40),
artists: track.artists ?? [], child: ArtistLink(
onOverflowArtistClick: () => ServiceUtils.pushNamed( artists: track.artists ?? [],
context, onOverflowArtistClick: () => ServiceUtils.pushNamed(
TrackPage.name, context,
pathParameters: { TrackPage.name,
"id": track.id!, pathParameters: {
}, "id": track.id!,
), },
), ),
), ),
), ),
), ),
trailing: Row( ),
mainAxisSize: MainAxisSize.min, trailing: Row(
children: [ mainAxisSize: MainAxisSize.min,
const SizedBox(width: 8), children: [
Text( const SizedBox(width: 8),
Duration(milliseconds: track.durationMs ?? 0) Text(
.toHumanReadableString(padZero: false), Duration(milliseconds: track.durationMs ?? 0)
maxLines: 1, .toHumanReadableString(padZero: false),
overflow: TextOverflow.ellipsis, maxLines: 1,
), overflow: TextOverflow.ellipsis,
TrackOptions( ),
track: track, TrackOptions(
playlistId: playlistId, track: track,
userPlaylist: userPlaylist, playlistId: playlistId,
showMenuCbRef: showOptionCbRef, userPlaylist: userPlaylist,
), showMenuCbRef: showOptionCbRef,
if (kIsDesktop) const Gap(10), ),
], if (kIsDesktop) const Gap(10),
), ],
), ),
), ),
), ),

View File

@ -6,7 +6,8 @@ class ButtonTile extends StatelessWidget {
final Widget? leading; final Widget? leading;
final Widget? trailing; final Widget? trailing;
final bool enabled; final bool enabled;
final void Function()? onPressed; final VoidCallback? onPressed;
final VoidCallback? onLongPress;
final bool selected; final bool selected;
final ButtonVariance style; final ButtonVariance style;
final EdgeInsets? padding; final EdgeInsets? padding;
@ -19,6 +20,7 @@ class ButtonTile extends StatelessWidget {
this.trailing, this.trailing,
this.enabled = true, this.enabled = true,
this.onPressed, this.onPressed,
this.onLongPress,
this.selected = false, this.selected = false,
this.padding, this.padding,
this.style = ButtonVariance.outline, this.style = ButtonVariance.outline,
@ -28,73 +30,78 @@ class ButtonTile extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ThemeData(:colorScheme, :typography) = Theme.of(context); final ThemeData(:colorScheme, :typography) = Theme.of(context);
return Button( return GestureDetector(
enabled: enabled, onLongPress: onLongPress,
onPressed: onPressed, child: Button(
style: style.copyWith( enabled: enabled,
padding: padding != null ? (context, states, value) => padding! : null, onPressed: onPressed,
decoration: (context, states, value) { style: style.copyWith(
final decoration = style.decoration(context, states) as BoxDecoration; padding:
padding != null ? (context, states, value) => padding! : null,
decoration: (context, states, value) {
final decoration =
style.decoration(context, states) as BoxDecoration;
if (selected) { if (selected) {
return switch (style) { return switch (style) {
ButtonVariance.outline => decoration.copyWith( ButtonVariance.outline => decoration.copyWith(
border: Border.all( border: Border.all(
color: colorScheme.primary, color: colorScheme.primary,
width: 1.0, width: 1.0,
),
color: colorScheme.primary.withAlpha(25),
), ),
color: colorScheme.primary.withAlpha(25), ButtonVariance.ghost || _ => decoration.copyWith(
), color: colorScheme.primary.withAlpha(25),
ButtonVariance.ghost || _ => decoration.copyWith( ),
color: colorScheme.primary.withAlpha(25), };
), }
};
}
return decoration; return decoration;
}, },
iconTheme: (context, states, value) { iconTheme: (context, states, value) {
final iconTheme = style.iconTheme(context, states); final iconTheme = style.iconTheme(context, states);
if (selected && style == ButtonVariance.outline) { if (selected && style == ButtonVariance.outline) {
return iconTheme.copyWith( return iconTheme.copyWith(
color: colorScheme.primary, color: colorScheme.primary,
); );
} }
return iconTheme; return iconTheme;
}, },
textStyle: (context, states, value) { textStyle: (context, states, value) {
final textStyle = style.textStyle(context, states); final textStyle = style.textStyle(context, states);
if (selected && style == ButtonVariance.outline) { if (selected && style == ButtonVariance.outline) {
return textStyle.copyWith( return textStyle.copyWith(
color: colorScheme.primary, color: colorScheme.primary,
); );
} }
return textStyle; return textStyle;
}, },
), ),
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
child: Basic( child: Basic(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
leadingAlignment: Alignment.center, leadingAlignment: Alignment.center,
trailingAlignment: Alignment.center, trailingAlignment: Alignment.center,
leading: leading, leading: leading,
title: title, title: title,
subtitle: subtitle:
style == ButtonVariance.outline && selected && subtitle != null style == ButtonVariance.outline && selected && subtitle != null
? DefaultTextStyle( ? DefaultTextStyle(
style: typography.xSmall.copyWith( style: typography.xSmall.copyWith(
color: colorScheme.primary, color: colorScheme.primary,
), ),
child: subtitle!, child: subtitle!,
) )
: subtitle, : subtitle,
trailing: trailing, trailing: trailing,
),
), ),
), ),
); );

View File

@ -0,0 +1,21 @@
import 'package:shadcn_flutter/shadcn_flutter.dart';
extension CopyWithButtonVarianceExtension on ButtonVariance {
ButtonVariance copyWith({
ButtonStateProperty<EdgeInsets>? padding,
ButtonStateProperty<Decoration>? decoration,
ButtonStateProperty<MouseCursor>? mouseCursor,
ButtonStateProperty<IconThemeData>? iconTheme,
ButtonStateProperty<EdgeInsets>? margin,
ButtonStateProperty<TextStyle>? textStyle,
}) {
return ButtonVariance(
padding: padding ?? this.padding,
decoration: decoration ?? this.decoration,
mouseCursor: mouseCursor ?? this.mouseCursor,
iconTheme: iconTheme ?? this.iconTheme,
margin: margin ?? this.margin,
textStyle: textStyle ?? this.textStyle,
);
}
}

View File

@ -43,7 +43,7 @@ class HomeGenresSection extends HookConsumerWidget {
useEffect(() { useEffect(() {
int times = 0; int times = 0;
Timer.periodic( final timer = Timer.periodic(
const Duration(seconds: 5), const Duration(seconds: 5),
(timer) { (timer) {
if (times > 5 || interactedRef.value) { if (times > 5 || interactedRef.value) {
@ -57,7 +57,10 @@ class HomeGenresSection extends HookConsumerWidget {
}, },
); );
return controller.dispose; return () {
timer.cancel();
controller.dispose();
};
}, []); }, []);
return SliverList.list( return SliverList.list(