diff --git a/lib/components/Album/AlbumView.dart b/lib/components/Album/AlbumView.dart index f9243009..95cea0d1 100644 --- a/lib/components/Album/AlbumView.dart +++ b/lib/components/Album/AlbumView.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/components/LoaderShimmers/ShimmerTrackTile.dart'; import 'package:spotube/components/Shared/HeartButton.dart'; import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; import 'package:spotube/components/Shared/TracksTableView.dart'; @@ -122,7 +123,7 @@ class AlbumView extends HookConsumerWidget { ); }, error: (error, _) => Text("Error $error"), - loading: () => const CircularProgressIndicator(), + loading: () => const ShimmerTrackTile(), ), ], ), diff --git a/lib/components/Artist/ArtistCard.dart b/lib/components/Artist/ArtistCard.dart index d85c238b..6168f683 100644 --- a/lib/components/Artist/ArtistCard.dart +++ b/lib/components/Artist/ArtistCard.dart @@ -1,7 +1,6 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:marquee/marquee.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/Shared/SpotubeMarqueeText.dart'; diff --git a/lib/components/Artist/ArtistProfile.dart b/lib/components/Artist/ArtistProfile.dart index e25a3139..1c7cb382 100644 --- a/lib/components/Artist/ArtistProfile.dart +++ b/lib/components/Artist/ArtistProfile.dart @@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/Album/AlbumCard.dart'; import 'package:spotube/components/Artist/ArtistCard.dart'; +import 'package:spotube/components/LoaderShimmers/ShimmerArtistProfile.dart'; import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; import 'package:spotube/components/Shared/TrackTile.dart'; import 'package:spotube/helpers/image-to-url-string.dart'; @@ -14,7 +15,6 @@ import 'package:spotube/helpers/readable-number.dart'; import 'package:spotube/helpers/zero-pad-num-str.dart'; import 'package:spotube/hooks/useBreakpointValue.dart'; import 'package:spotube/hooks/useBreakpoints.dart'; -import 'package:spotube/hooks/useForceUpdate.dart'; import 'package:spotube/models/CurrentPlaylist.dart'; import 'package:spotube/models/Logger.dart'; import 'package:spotube/provider/Playback.dart'; @@ -49,7 +49,6 @@ class ArtistProfile extends HookConsumerWidget { ); final breakpoint = useBreakpoints(); - final update = useForceUpdate(); final Playback playback = ref.watch(playbackProvider); @@ -66,268 +65,264 @@ class ArtistProfile extends HookConsumerWidget { leading: BackButton(), ), body: artistsSnapshot.when( - data: (data) { - return SingleChildScrollView( - controller: parentScrollController, - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - runAlignment: WrapAlignment.center, - children: [ - const SizedBox(width: 50), - CircleAvatar( - radius: avatarWidth, - backgroundImage: CachedNetworkImageProvider( - imageToUrlString(data.images), - ), + data: (data) { + return SingleChildScrollView( + controller: parentScrollController, + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + runAlignment: WrapAlignment.center, + children: [ + const SizedBox(width: 50), + CircleAvatar( + radius: avatarWidth, + backgroundImage: CachedNetworkImageProvider( + imageToUrlString(data.images), ), - Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: Colors.blue, - borderRadius: BorderRadius.circular(50)), - child: Text(data.type!.toUpperCase(), - style: chipTextVariant?.copyWith( - color: Colors.white)), - ), - Text( - data.name!, - style: breakpoint.isSm - ? textTheme.headline4 - : textTheme.headline2, - ), - Text( - "${toReadableNumber(data.followers!.total!.toDouble())} followers", - style: breakpoint.isSm - ? textTheme.bodyText1 - : textTheme.headline5, - ), - const SizedBox(height: 20), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - isFollowingSnapshot.when( - data: (isFollowing) { - return OutlinedButton( - onPressed: () async { - try { - isFollowing - ? await spotify.me.unfollow( - FollowingType.artist, - [artistId], - ) - : await spotify.me.follow( - FollowingType.artist, - [artistId], - ); - } catch (e, stack) { - logger.e( - "FollowButton.onPressed", - e, - stack, - ); - } finally { - ref.refresh( - currentUserFollowsArtistQuery( - artistId), - ); - } - }, - child: Text( - isFollowing - ? "Following" - : "Follow", - ), - ); - }, - error: (error, stackTrace) => Container(), - loading: () => - const CircularProgressIndicator - .adaptive()), - IconButton( - icon: const Icon(Icons.share_rounded), - onPressed: () { - Clipboard.setData( - ClipboardData( - text: data.externalUrls?.spotify), - ).then((val) { - ScaffoldMessenger.of(context) - .showSnackBar( - const SnackBar( - width: 300, - behavior: SnackBarBehavior.floating, - content: Text( - "Artist URL copied to clipboard", - textAlign: TextAlign.center, - ), - ), - ); - }); - }, - ) - ], - ) - ], - ), - ), - ], - ), - const SizedBox(height: 50), - topTracksSnapshot.when( - data: (topTracks) { - final isPlaylistPlaying = - playback.currentPlaylist?.id == data.id; - playPlaylist(List tracks, - {Track? currentTrack}) async { - currentTrack ??= tracks.first; - if (!isPlaylistPlaying) { - playback.setCurrentPlaylist = CurrentPlaylist( - tracks: tracks, - id: data.id!, - name: "${data.name!} To Tracks", - thumbnail: imageToUrlString(data.images), - ); - playback.setCurrentTrack = currentTrack; - } else if (isPlaylistPlaying && - currentTrack.id != null && - currentTrack.id != playback.currentTrack?.id) { - playback.setCurrentTrack = currentTrack; - } - await playback.startPlaying(); - } - - return Column(children: [ - Row( - children: [ - Text( - "Top Tracks", - style: Theme.of(context).textTheme.headline4, - ), - Container( - margin: - const EdgeInsets.symmetric(horizontal: 5), - decoration: BoxDecoration( - color: Theme.of(context).primaryColor, - borderRadius: BorderRadius.circular(50), - ), - child: IconButton( - icon: Icon(isPlaylistPlaying - ? Icons.stop_rounded - : Icons.play_arrow_rounded), - color: Colors.white, - onPressed: () => - playPlaylist(topTracks.toList()), - ), - ) - ], - ), - ...topTracks.toList().asMap().entries.map((track) { - String duration = - "${track.value.duration?.inMinutes.remainder(60)}:${zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; - String? thumbnailUrl = imageToUrlString( - track.value.album?.images, - index: - (track.value.album?.images?.length ?? 1) - - 1); - return TrackTile( - playback, - duration: duration, - track: track, - thumbnailUrl: thumbnailUrl, - onTrackPlayButtonPressed: (currentTrack) => - playPlaylist( - topTracks.toList(), - currentTrack: track.value, - ), - ); - }), - ]); - }, - error: (error, stack) => - Text("Failed to find top tracks $error"), - loading: () => const Center( - child: CircularProgressIndicator.adaptive()), - ), - const SizedBox(height: 50), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "Albums", - style: Theme.of(context).textTheme.headline4, - ), - TextButton( - child: const Text("See All"), - onPressed: () { - GoRouter.of(context).push( - "/artist-album/$artistId", - extra: data.name ?? "KRTX", - ); - }, - ) - ], - ), - const SizedBox(height: 10), - albums.when( - data: (albums) { - return Scrollbar( - controller: scrollController, - child: SingleChildScrollView( - controller: scrollController, - scrollDirection: Axis.horizontal, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: albums.items - ?.map((album) => AlbumCard(album)) - .toList() ?? - [], + ), + Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: Colors.blue, + borderRadius: BorderRadius.circular(50)), + child: Text(data.type!.toUpperCase(), + style: chipTextVariant?.copyWith( + color: Colors.white)), ), + Text( + data.name!, + style: breakpoint.isSm + ? textTheme.headline4 + : textTheme.headline2, + ), + Text( + "${toReadableNumber(data.followers!.total!.toDouble())} followers", + style: breakpoint.isSm + ? textTheme.bodyText1 + : textTheme.headline5, + ), + const SizedBox(height: 20), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + isFollowingSnapshot.when( + data: (isFollowing) { + return OutlinedButton( + onPressed: () async { + try { + isFollowing + ? await spotify.me.unfollow( + FollowingType.artist, + [artistId], + ) + : await spotify.me.follow( + FollowingType.artist, + [artistId], + ); + } catch (e, stack) { + logger.e( + "FollowButton.onPressed", + e, + stack, + ); + } finally { + ref.refresh( + currentUserFollowsArtistQuery( + artistId), + ); + } + }, + child: Text( + isFollowing ? "Following" : "Follow", + ), + ); + }, + error: (error, stackTrace) => Container(), + loading: () => + const CircularProgressIndicator + .adaptive()), + IconButton( + icon: const Icon(Icons.share_rounded), + onPressed: () { + Clipboard.setData( + ClipboardData( + text: data.externalUrls?.spotify), + ).then((val) { + ScaffoldMessenger.of(context) + .showSnackBar( + const SnackBar( + width: 300, + behavior: SnackBarBehavior.floating, + content: Text( + "Artist URL copied to clipboard", + textAlign: TextAlign.center, + ), + ), + ); + }); + }, + ) + ], + ) + ], + ), + ), + ], + ), + const SizedBox(height: 50), + topTracksSnapshot.when( + data: (topTracks) { + final isPlaylistPlaying = + playback.currentPlaylist?.id == data.id; + playPlaylist(List tracks, + {Track? currentTrack}) async { + currentTrack ??= tracks.first; + if (!isPlaylistPlaying) { + playback.setCurrentPlaylist = CurrentPlaylist( + tracks: tracks, + id: data.id!, + name: "${data.name!} To Tracks", + thumbnail: imageToUrlString(data.images), + ); + playback.setCurrentTrack = currentTrack; + } else if (isPlaylistPlaying && + currentTrack.id != null && + currentTrack.id != playback.currentTrack?.id) { + playback.setCurrentTrack = currentTrack; + } + await playback.startPlaying(); + } + + return Column(children: [ + Row( + children: [ + Text( + "Top Tracks", + style: Theme.of(context).textTheme.headline4, + ), + Container( + margin: const EdgeInsets.symmetric(horizontal: 5), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + borderRadius: BorderRadius.circular(50), + ), + child: IconButton( + icon: Icon(isPlaylistPlaying + ? Icons.stop_rounded + : Icons.play_arrow_rounded), + color: Colors.white, + onPressed: () => + playPlaylist(topTracks.toList()), + ), + ) + ], + ), + ...topTracks.toList().asMap().entries.map((track) { + String duration = + "${track.value.duration?.inMinutes.remainder(60)}:${zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; + String? thumbnailUrl = imageToUrlString( + track.value.album?.images, + index: + (track.value.album?.images?.length ?? 1) - 1); + return TrackTile( + playback, + duration: duration, + track: track, + thumbnailUrl: thumbnailUrl, + onTrackPlayButtonPressed: (currentTrack) => + playPlaylist( + topTracks.toList(), + currentTrack: track.value, + ), + ); + }), + ]); + }, + error: (error, stack) => + Text("Failed to find top tracks $error"), + loading: () => const Center( + child: CircularProgressIndicator.adaptive()), + ), + const SizedBox(height: 50), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Albums", + style: Theme.of(context).textTheme.headline4, + ), + TextButton( + child: const Text("See All"), + onPressed: () { + GoRouter.of(context).push( + "/artist-album/$artistId", + extra: data.name ?? "KRTX", + ); + }, + ) + ], + ), + const SizedBox(height: 10), + albums.when( + data: (albums) { + return Scrollbar( + controller: scrollController, + child: SingleChildScrollView( + controller: scrollController, + scrollDirection: Axis.horizontal, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: albums.items + ?.map((album) => AlbumCard(album)) + .toList() ?? + [], ), - ); - }, - error: (error, stackTrack) => - Text("Failed to get Artist albums $error"), - loading: () => const CircularProgressIndicator.adaptive(), - ), - const SizedBox(height: 20), - Text( - "Fans also likes", - style: Theme.of(context).textTheme.headline4, - ), - const SizedBox(height: 10), - relatedArtists.when( - data: (artists) { - return Center( - child: Wrap( - spacing: 20, - runSpacing: 20, - children: artists - .map((artist) => ArtistCard(artist)) - .toList(), - ), - ); - }, - error: (error, stackTrack) => - Text("Failed to get Artist albums $error"), - loading: () => const CircularProgressIndicator.adaptive(), - ), - ], - ), - ); - }, - error: (_, __) => const Text("Life's miserable"), - loading: () => - const Center(child: CircularProgressIndicator.adaptive())), + ), + ); + }, + error: (error, stackTrack) => + Text("Failed to get Artist albums $error"), + loading: () => const CircularProgressIndicator.adaptive(), + ), + const SizedBox(height: 20), + Text( + "Fans also likes", + style: Theme.of(context).textTheme.headline4, + ), + const SizedBox(height: 10), + relatedArtists.when( + data: (artists) { + return Center( + child: Wrap( + spacing: 20, + runSpacing: 20, + children: artists + .map((artist) => ArtistCard(artist)) + .toList(), + ), + ); + }, + error: (error, stackTrack) => + Text("Failed to get Artist albums $error"), + loading: () => const CircularProgressIndicator.adaptive(), + ), + ], + ), + ); + }, + error: (_, __) => const Text("Life's miserable"), + loading: () => const ShimmerArtistProfile(), + ), ), ); } diff --git a/lib/components/Category/CategoryCard.dart b/lib/components/Category/CategoryCard.dart index 8308f2cc..a92e98ee 100644 --- a/lib/components/Category/CategoryCard.dart +++ b/lib/components/Category/CategoryCard.dart @@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/components/LoaderShimmers/ShimmerPlaybuttonCard.dart'; import 'package:spotube/components/Playlist/PlaylistCard.dart'; import 'package:spotube/hooks/usePaginatedFutureProvider.dart'; import 'package:spotube/models/Logger.dart'; @@ -71,6 +72,12 @@ class CategoryCard extends HookConsumerWidget { scrollController: scrollController, scrollDirection: Axis.horizontal, builderDelegate: PagedChildBuilderDelegate( + firstPageProgressIndicatorBuilder: (context) { + return const ShimmerPlaybuttonCard(); + }, + newPageProgressIndicatorBuilder: (context) { + return const ShimmerPlaybuttonCard(); + }, itemBuilder: (context, playlist, index) { return PlaylistCard(playlist); }, diff --git a/lib/components/Home/Home.dart b/lib/components/Home/Home.dart index b4968d7f..69bfce40 100644 --- a/lib/components/Home/Home.dart +++ b/lib/components/Home/Home.dart @@ -11,6 +11,7 @@ import 'package:spotify/spotify.dart' hide Image, Player, Search; import 'package:spotube/components/Category/CategoryCard.dart'; import 'package:spotube/components/Home/Sidebar.dart'; import 'package:spotube/components/Home/SpotubeNavigationBar.dart'; +import 'package:spotube/components/LoaderShimmers/ShimmerCategories.dart'; import 'package:spotube/components/Lyrics/SyncedLyrics.dart'; import 'package:spotube/components/Search/Search.dart'; import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; @@ -138,6 +139,10 @@ class Home extends HookConsumerWidget { pagingController: pagingController, builderDelegate: PagedChildBuilderDelegate( + firstPageProgressIndicatorBuilder: (_) => + const ShimmerCategories(), + newPageProgressIndicatorBuilder: (_) => + const ShimmerCategories(), itemBuilder: (context, item, index) { return CategoryCard(item); }, diff --git a/lib/components/LoaderShimmers/ShimmerArtistProfile.dart b/lib/components/LoaderShimmers/ShimmerArtistProfile.dart new file mode 100644 index 00000000..0fa1dbe6 --- /dev/null +++ b/lib/components/LoaderShimmers/ShimmerArtistProfile.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:skeleton_text/skeleton_text.dart'; +import 'package:spotube/components/LoaderShimmers/ShimmerTrackTile.dart'; +import 'package:spotube/extensions/ShimmerColorTheme.dart'; +import 'package:spotube/hooks/useBreakpointValue.dart'; + +class ShimmerArtistProfile extends HookWidget { + const ShimmerArtistProfile({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final shimmerColor = + Theme.of(context).extension()!.shimmerColor!; + final shimmerBackgroundColor = Theme.of(context) + .extension()! + .shimmerBackgroundColor!; + + final avatarWidth = useBreakpointValue( + sm: MediaQuery.of(context).size.width * 0.80, + md: MediaQuery.of(context).size.width * 0.50, + lg: MediaQuery.of(context).size.width * 0.30, + xl: MediaQuery.of(context).size.width * 0.30, + xxl: MediaQuery.of(context).size.width * 0.30, + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(20), + child: SkeletonAnimation( + shimmerColor: shimmerColor, + borderRadius: BorderRadius.circular(avatarWidth), + shimmerDuration: 1000, + child: Container( + width: avatarWidth, + height: avatarWidth, + decoration: BoxDecoration( + color: shimmerBackgroundColor, + borderRadius: BorderRadius.circular(avatarWidth), + ), + ), + ), + ), + const SizedBox(width: 10), + const Flexible(child: ShimmerTrackTile()), + ], + ); + } +} diff --git a/lib/components/LoaderShimmers/ShimmerCategories.dart b/lib/components/LoaderShimmers/ShimmerCategories.dart new file mode 100644 index 00000000..7c0d5227 --- /dev/null +++ b/lib/components/LoaderShimmers/ShimmerCategories.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:skeleton_text/skeleton_text.dart'; +import 'package:spotube/components/LoaderShimmers/ShimmerPlaybuttonCard.dart'; +import 'package:spotube/extensions/ShimmerColorTheme.dart'; + +class ShimmerCategories extends StatelessWidget { + const ShimmerCategories({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final shimmerColor = + Theme.of(context).extension()!.shimmerColor!; + final shimmerBackgroundColor = Theme.of(context) + .extension()! + .shimmerBackgroundColor!; + + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 15), + child: SkeletonAnimation( + shimmerColor: shimmerBackgroundColor, + borderRadius: BorderRadius.circular(20), + shimmerDuration: 1000, + child: Container( + width: 150, + height: 15, + decoration: BoxDecoration( + color: shimmerColor, + borderRadius: BorderRadius.circular(10), + ), + margin: const EdgeInsets.only(top: 10), + ), + ), + ), + const ShimmerPlaybuttonCard(count: 7), + ], + ), + ); + } +} diff --git a/lib/components/LoaderShimmers/ShimmerPlaybuttonCard.dart b/lib/components/LoaderShimmers/ShimmerPlaybuttonCard.dart new file mode 100644 index 00000000..e15fcc81 --- /dev/null +++ b/lib/components/LoaderShimmers/ShimmerPlaybuttonCard.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:skeleton_text/skeleton_text.dart'; +import 'package:spotube/extensions/ShimmerColorTheme.dart'; + +class ShimmerPlaybuttonCard extends StatelessWidget { + final int count; + const ShimmerPlaybuttonCard({Key? key, this.count = 4}) : super(key: key); + + @override + Widget build(BuildContext context) { + final shimmerColor = + Theme.of(context).extension()!.shimmerColor!; + final shimmerBackgroundColor = Theme.of(context) + .extension()! + .shimmerBackgroundColor!; + + final card = Stack( + children: [ + SkeletonAnimation( + shimmerColor: shimmerColor, + borderRadius: BorderRadius.circular(20), + shimmerDuration: 1000, + child: Container( + width: 200, + height: 220, + decoration: BoxDecoration( + color: shimmerBackgroundColor, + borderRadius: BorderRadius.circular(10), + ), + margin: const EdgeInsets.only(top: 10), + ), + ), + Column( + children: [ + SkeletonAnimation( + shimmerColor: shimmerBackgroundColor, + borderRadius: BorderRadius.circular(20), + shimmerDuration: 1000, + child: Container( + width: 200, + height: 180, + decoration: BoxDecoration( + color: shimmerColor, + borderRadius: BorderRadius.circular(10), + ), + margin: const EdgeInsets.only(top: 10), + ), + ), + const SizedBox(height: 5), + SkeletonAnimation( + shimmerColor: shimmerBackgroundColor, + borderRadius: BorderRadius.circular(20), + shimmerDuration: 1000, + child: Container( + width: 150, + height: 10, + decoration: BoxDecoration( + color: shimmerColor, + borderRadius: BorderRadius.circular(10), + ), + margin: const EdgeInsets.only(top: 10), + ), + ), + ], + ), + ], + ); + + return SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + scrollDirection: Axis.horizontal, + child: Row( + children: [ + Row( + children: List.generate( + count, + (_) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 15), + child: card, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/components/LoaderShimmers/ShimmerTrackTile.dart b/lib/components/LoaderShimmers/ShimmerTrackTile.dart new file mode 100644 index 00000000..abf45b16 --- /dev/null +++ b/lib/components/LoaderShimmers/ShimmerTrackTile.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:skeleton_text/skeleton_text.dart'; +import 'package:spotube/extensions/ShimmerColorTheme.dart'; + +class ShimmerTrackTile extends StatelessWidget { + const ShimmerTrackTile({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final shimmerColor = + Theme.of(context).extension()!.shimmerColor!; + final shimmerBackgroundColor = Theme.of(context) + .extension()! + .shimmerBackgroundColor!; + + return Padding( + padding: const EdgeInsets.only(top: 30), + child: ListView.builder( + scrollDirection: Axis.vertical, + physics: const NeverScrollableScrollPhysics(), + itemCount: 5, + shrinkWrap: true, + itemBuilder: (BuildContext context, int index) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + SkeletonAnimation( + shimmerColor: shimmerColor, + borderRadius: BorderRadius.circular(20), + shimmerDuration: 1000, + child: Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: shimmerBackgroundColor, + borderRadius: BorderRadius.circular(10), + ), + margin: const EdgeInsets.only(top: 10), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SkeletonAnimation( + shimmerColor: shimmerColor, + borderRadius: BorderRadius.circular(20), + shimmerDuration: 1000, + child: Container( + height: 15, + decoration: BoxDecoration( + color: shimmerBackgroundColor, + borderRadius: BorderRadius.circular(10), + ), + margin: const EdgeInsets.only(top: 10), + ), + ), + SkeletonAnimation( + shimmerColor: shimmerColor, + borderRadius: BorderRadius.circular(20), + shimmerDuration: 1000, + child: Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * .8), + height: 10, + decoration: BoxDecoration( + color: shimmerBackgroundColor, + borderRadius: BorderRadius.circular(10), + ), + margin: const EdgeInsets.only(top: 10), + ), + ), + ], + ), + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/lib/components/Playlist/PlaylistView.dart b/lib/components/Playlist/PlaylistView.dart index 247520f8..43730971 100644 --- a/lib/components/Playlist/PlaylistView.dart +++ b/lib/components/Playlist/PlaylistView.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/components/LoaderShimmers/ShimmerTrackTile.dart'; import 'package:spotube/components/Shared/HeartButton.dart'; import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; import 'package:spotube/components/Shared/TracksTableView.dart'; @@ -62,7 +63,7 @@ class PlaylistView extends HookConsumerWidget { // nav back const BackButton(), // heart playlist - if (auth.isLoggedIn) + if (auth.isLoggedIn && playlist.id != "user-liked-tracks") meSnapshot.when( data: (me) { final query = playlistIsFollowedQuery(jsonEncode( @@ -101,27 +102,28 @@ class PlaylistView extends HookConsumerWidget { loading: () => const CircularProgressIndicator(), ), - IconButton( - icon: const Icon(Icons.share_rounded), - onPressed: () { - final data = - "https://open.spotify.com/playlist/${playlist.id}"; - Clipboard.setData( - ClipboardData(text: data), - ).then((_) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - width: 300, - behavior: SnackBarBehavior.floating, - content: Text( - "Copied $data to clipboard", - textAlign: TextAlign.center, + if (playlist.id != "user-liked-tracks") + IconButton( + icon: const Icon(Icons.share_rounded), + onPressed: () { + final data = + "https://open.spotify.com/playlist/${playlist.id}"; + Clipboard.setData( + ClipboardData(text: data), + ).then((_) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + width: 300, + behavior: SnackBarBehavior.floating, + content: Text( + "Copied $data to clipboard", + textAlign: TextAlign.center, + ), ), - ), - ); - }); - }, - ), + ); + }); + }, + ), // play playlist IconButton( icon: Icon( @@ -158,7 +160,7 @@ class PlaylistView extends HookConsumerWidget { ); }, error: (error, _) => Text("Error $error"), - loading: () => const CircularProgressIndicator(), + loading: () => const ShimmerTrackTile(), ), ], ), diff --git a/lib/components/Settings/Settings.dart b/lib/components/Settings/Settings.dart index 59a04419..9a1f9b42 100644 --- a/lib/components/Settings/Settings.dart +++ b/lib/components/Settings/Settings.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotube/components/Settings/About.dart'; import 'package:spotube/components/Settings/ColorSchemePickerDialog.dart'; import 'package:spotube/components/Settings/SettingsHotkeyTile.dart'; diff --git a/lib/extensions/ShimmerColorTheme.dart b/lib/extensions/ShimmerColorTheme.dart new file mode 100644 index 00000000..22a1ce84 --- /dev/null +++ b/lib/extensions/ShimmerColorTheme.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +class ShimmerColorTheme extends ThemeExtension { + final Color? shimmerColor; + final Color? shimmerBackgroundColor; + + ShimmerColorTheme({ + this.shimmerBackgroundColor, + this.shimmerColor, + }); + + @override + ThemeExtension copyWith( + {Color? shimmerColor, Color? shimmerBackgroundColor}) { + return ShimmerColorTheme( + shimmerBackgroundColor: + shimmerBackgroundColor ?? this.shimmerBackgroundColor, + shimmerColor: shimmerColor ?? this.shimmerColor, + ); + } + + @override + ThemeExtension lerp( + ThemeExtension? other, double t) { + if (other is! ShimmerColorTheme) { + return this; + } + return ShimmerColorTheme( + shimmerBackgroundColor: + Color.lerp(shimmerBackgroundColor, other.shimmerBackgroundColor, t), + shimmerColor: Color.lerp(shimmerColor, other.shimmerColor, t), + ); + } +} diff --git a/lib/themes/dark-theme.dart b/lib/themes/dark-theme.dart index bcf96aac..4bbaf3ed 100644 --- a/lib/themes/dark-theme.dart +++ b/lib/themes/dark-theme.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:spotube/extensions/ShimmerColorTheme.dart'; ThemeData darkTheme({ required MaterialColor accentMaterialColor, @@ -7,6 +8,12 @@ ThemeData darkTheme({ return ThemeData( useMaterial3: true, brightness: Brightness.dark, + extensions: [ + ShimmerColorTheme( + shimmerBackgroundColor: backgroundMaterialColor[700], + shimmerColor: backgroundMaterialColor[800], + ) + ], primaryColor: accentMaterialColor, primarySwatch: accentMaterialColor, backgroundColor: backgroundMaterialColor[900], diff --git a/lib/themes/light-theme.dart b/lib/themes/light-theme.dart index fe642365..65bb88cd 100644 --- a/lib/themes/light-theme.dart +++ b/lib/themes/light-theme.dart @@ -1,14 +1,15 @@ import 'package:flutter/material.dart'; +import 'package:spotube/extensions/ShimmerColorTheme.dart'; final materialWhite = MaterialColor(Colors.white.value, { 50: Colors.white, 100: Colors.blueGrey[50]!, 200: Colors.white, 300: Colors.white, - 400: Colors.white, + 400: Colors.blueGrey[300]!, 500: Colors.blueGrey, 600: Colors.white, - 700: Colors.white, + 700: Colors.grey[700]!, 800: Colors.white, 900: Colors.white, }); @@ -19,6 +20,12 @@ ThemeData lightTheme({ }) { return ThemeData( useMaterial3: true, + extensions: [ + ShimmerColorTheme( + shimmerBackgroundColor: backgroundMaterialColor[200], + shimmerColor: backgroundMaterialColor[300], + ) + ], primaryColor: accentMaterialColor, primarySwatch: accentMaterialColor, buttonTheme: ButtonThemeData( diff --git a/pubspec.lock b/pubspec.lock index ab2e6dc3..ec639796 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -702,6 +702,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.1" + skeleton_text: + dependency: "direct main" + description: + name: skeleton_text + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index b84ae6dc..a25da890 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -61,6 +61,7 @@ dependencies: version: ^2.0.0 audio_service: ^0.18.4 hookified_infinite_scroll_pagination: ^0.1.0 + skeleton_text: ^3.0.0 dev_dependencies: flutter_test: