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:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
@ -21,9 +20,6 @@ class TrackPresentation extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
final headerTextStyle = context.theme.typography.small.copyWith(
color: context.theme.colorScheme.mutedForeground,
);
final scrollController = useScrollController();
final focusNode = useFocusNode();
final scale = context.theme.scaling;
@ -66,10 +62,11 @@ class TrackPresentation extends HookConsumerWidget {
TrackPresentationModifiersSection(
focusNode: focusNode,
),
ListTile(
titleTextStyle: headerTextStyle,
subtitleTextStyle: headerTextStyle,
leadingAndTrailingTextStyle: headerTextStyle,
Basic(
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
leading: constrains.mdAndUp ? const Text(" #") : null,
title: Row(
children: [
@ -85,7 +82,7 @@ class TrackPresentation extends HookConsumerWidget {
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);
if (state.selectedTracks.isNotEmpty) {
notifier.selectTrack(track);
if (state.selectedTracks.contains(track)) {
notifier.deselectTrack(track);
} else {
notifier.selectTrack(track);
}
return;
}

View File

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

View File

@ -6,7 +6,8 @@ class ButtonTile extends StatelessWidget {
final Widget? leading;
final Widget? trailing;
final bool enabled;
final void Function()? onPressed;
final VoidCallback? onPressed;
final VoidCallback? onLongPress;
final bool selected;
final ButtonVariance style;
final EdgeInsets? padding;
@ -19,6 +20,7 @@ class ButtonTile extends StatelessWidget {
this.trailing,
this.enabled = true,
this.onPressed,
this.onLongPress,
this.selected = false,
this.padding,
this.style = ButtonVariance.outline,
@ -28,73 +30,78 @@ class ButtonTile extends StatelessWidget {
Widget build(BuildContext context) {
final ThemeData(:colorScheme, :typography) = Theme.of(context);
return Button(
enabled: enabled,
onPressed: onPressed,
style: style.copyWith(
padding: padding != null ? (context, states, value) => padding! : null,
decoration: (context, states, value) {
final decoration = style.decoration(context, states) as BoxDecoration;
return GestureDetector(
onLongPress: onLongPress,
child: Button(
enabled: enabled,
onPressed: onPressed,
style: style.copyWith(
padding:
padding != null ? (context, states, value) => padding! : null,
decoration: (context, states, value) {
final decoration =
style.decoration(context, states) as BoxDecoration;
if (selected) {
return switch (style) {
ButtonVariance.outline => decoration.copyWith(
border: Border.all(
color: colorScheme.primary,
width: 1.0,
if (selected) {
return switch (style) {
ButtonVariance.outline => decoration.copyWith(
border: Border.all(
color: colorScheme.primary,
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;
},
iconTheme: (context, states, value) {
final iconTheme = style.iconTheme(context, states);
return decoration;
},
iconTheme: (context, states, value) {
final iconTheme = style.iconTheme(context, states);
if (selected && style == ButtonVariance.outline) {
return iconTheme.copyWith(
color: colorScheme.primary,
);
}
if (selected && style == ButtonVariance.outline) {
return iconTheme.copyWith(
color: colorScheme.primary,
);
}
return iconTheme;
},
textStyle: (context, states, value) {
final textStyle = style.textStyle(context, states);
return iconTheme;
},
textStyle: (context, states, value) {
final textStyle = style.textStyle(context, states);
if (selected && style == ButtonVariance.outline) {
return textStyle.copyWith(
color: colorScheme.primary,
);
}
if (selected && style == ButtonVariance.outline) {
return textStyle.copyWith(
color: colorScheme.primary,
);
}
return textStyle;
},
),
alignment: Alignment.centerLeft,
child: SizedBox(
width: double.infinity,
child: Basic(
padding: EdgeInsets.zero,
leadingAlignment: Alignment.center,
trailingAlignment: Alignment.center,
leading: leading,
title: title,
subtitle:
style == ButtonVariance.outline && selected && subtitle != null
? DefaultTextStyle(
style: typography.xSmall.copyWith(
color: colorScheme.primary,
),
child: subtitle!,
)
: subtitle,
trailing: trailing,
return textStyle;
},
),
alignment: Alignment.centerLeft,
child: SizedBox(
width: double.infinity,
child: Basic(
padding: EdgeInsets.zero,
leadingAlignment: Alignment.center,
trailingAlignment: Alignment.center,
leading: leading,
title: title,
subtitle:
style == ButtonVariance.outline && selected && subtitle != null
? DefaultTextStyle(
style: typography.xSmall.copyWith(
color: colorScheme.primary,
),
child: subtitle!,
)
: subtitle,
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(() {
int times = 0;
Timer.periodic(
final timer = Timer.periodic(
const Duration(seconds: 5),
(timer) {
if (times > 5 || interactedRef.value) {
@ -57,7 +57,10 @@ class HomeGenresSection extends HookConsumerWidget {
},
);
return controller.dispose;
return () {
timer.cancel();
controller.dispose();
};
}, []);
return SliverList.list(