mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-14 00:15:17 +00:00
feat(artist): modularize page and add wikipedia section
This commit is contained in:
parent
4511a0bd00
commit
2a69886556
@ -107,4 +107,5 @@ abstract class SpotubeIcons {
|
||||
static const eye = FeatherIcons.eye;
|
||||
static const noEye = FeatherIcons.eyeOff;
|
||||
static const normalize = FeatherIcons.barChart2;
|
||||
static const wikipedia = SimpleIcons.wikipedia;
|
||||
}
|
||||
|
@ -14,6 +14,7 @@ import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart'
|
||||
import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart';
|
||||
import 'package:spotube/components/playlist/playlist_card.dart';
|
||||
import 'package:spotube/components/shared/waypoint.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
@ -120,11 +121,12 @@ class UserPlaylists extends HookConsumerWidget {
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: 10),
|
||||
),
|
||||
SliverGrid.builder(
|
||||
SliverLayoutBuilder(builder: (context, constrains) {
|
||||
return SliverGrid.builder(
|
||||
itemCount: playlists.length + 1,
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 200,
|
||||
mainAxisExtent: DesktopTools.platform.isMobile ? 225 : 250,
|
||||
mainAxisExtent: constrains.smAndDown ? 225 : 250,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
),
|
||||
@ -144,7 +146,8 @@ class UserPlaylists extends HookConsumerWidget {
|
||||
|
||||
return PlaylistCard(playlists[index]);
|
||||
},
|
||||
)
|
||||
);
|
||||
})
|
||||
],
|
||||
),
|
||||
),
|
||||
|
28
lib/extensions/color.dart
Normal file
28
lib/extensions/color.dart
Normal file
@ -0,0 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
extension ColorAlterer on Color {
|
||||
Color darken(double amount) {
|
||||
assert(amount >= 0 && amount <= 1);
|
||||
final hsl = HSLColor.fromColor(this);
|
||||
final hslDark = hsl.withLightness((hsl.lightness - amount).clamp(0.0, 1.0));
|
||||
return hslDark.toColor();
|
||||
}
|
||||
|
||||
Color lighten(double amount) {
|
||||
assert(amount >= 0 && amount <= 1);
|
||||
final hsl = HSLColor.fromColor(this);
|
||||
final hslLight =
|
||||
hsl.withLightness((hsl.lightness + amount).clamp(0.0, 1.0));
|
||||
return hslLight.toColor();
|
||||
}
|
||||
|
||||
bool isLight() {
|
||||
final luminance = computeLuminance();
|
||||
return luminance > 0.5;
|
||||
}
|
||||
|
||||
bool isDark() {
|
||||
final luminance = computeLuminance();
|
||||
return luminance <= 0.5;
|
||||
}
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
// ignore: constant_identifier_names
|
||||
@ -9,6 +10,29 @@ const Breakpoints = (
|
||||
xl: 1280.0,
|
||||
);
|
||||
|
||||
extension SliverBreakpoints on SliverConstraints {
|
||||
bool get isXs => crossAxisExtent <= Breakpoints.xs;
|
||||
bool get isSm =>
|
||||
crossAxisExtent > Breakpoints.xs && crossAxisExtent <= Breakpoints.sm;
|
||||
bool get isMd =>
|
||||
crossAxisExtent > Breakpoints.sm && crossAxisExtent <= Breakpoints.md;
|
||||
bool get isLg =>
|
||||
crossAxisExtent > Breakpoints.md && crossAxisExtent <= Breakpoints.lg;
|
||||
bool get isXl =>
|
||||
crossAxisExtent > Breakpoints.lg && crossAxisExtent <= Breakpoints.xl;
|
||||
bool get is2Xl => crossAxisExtent > Breakpoints.xl;
|
||||
|
||||
bool get smAndUp => isSm || isMd || isLg || isXl || is2Xl;
|
||||
bool get mdAndUp => isMd || isLg || isXl || is2Xl;
|
||||
bool get lgAndUp => isLg || isXl || is2Xl;
|
||||
bool get xlAndUp => isXl || is2Xl;
|
||||
|
||||
bool get smAndDown => isXs || isSm;
|
||||
bool get mdAndDown => isXs || isSm || isMd;
|
||||
bool get lgAndDown => isXs || isSm || isMd || isLg;
|
||||
bool get xlAndDown => isXs || isSm || isMd || isLg || isXl;
|
||||
}
|
||||
|
||||
extension ContainerBreakpoints on BoxConstraints {
|
||||
bool get isXs => biggest.width <= Breakpoints.xs;
|
||||
bool get isSm =>
|
||||
|
@ -1,32 +1,19 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:fl_query_hooks/fl_query_hooks.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
|
||||
import 'package:spotube/components/shared/shimmers/shimmer_artist_profile.dart';
|
||||
import 'package:spotube/components/shared/page_window_title_bar.dart';
|
||||
import 'package:spotube/components/shared/track_tile/track_tile.dart';
|
||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||
import 'package:spotube/components/artist/artist_album_list.dart';
|
||||
import 'package:spotube/components/artist/artist_card.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/components/shared/shimmers/shimmer_artist_profile.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
|
||||
import 'package:spotube/models/logger.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/provider/blacklist_provider.dart';
|
||||
import 'package:spotube/provider/spotify_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/pages/artist/section/footer.dart';
|
||||
import 'package:spotube/pages/artist/section/header.dart';
|
||||
import 'package:spotube/pages/artist/section/related_artists.dart';
|
||||
import 'package:spotube/pages/artist/section/top_tracks.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
|
||||
import 'package:spotube/utils/primitive_utils.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
class ArtistPage extends HookConsumerWidget {
|
||||
final String artistId;
|
||||
final logger = getLogger(ArtistPage);
|
||||
@ -34,427 +21,61 @@ class ArtistPage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
SpotifyApi spotify = ref.watch(spotifyProvider);
|
||||
final parentScrollController = useScrollController();
|
||||
final scrollController = useScrollController();
|
||||
final theme = Theme.of(context);
|
||||
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
||||
final textTheme = theme.textTheme;
|
||||
final chipTextVariant = useBreakpointValue(
|
||||
xs: textTheme.bodySmall,
|
||||
sm: textTheme.bodySmall,
|
||||
md: textTheme.bodyMedium,
|
||||
lg: textTheme.bodyLarge,
|
||||
xl: textTheme.titleSmall,
|
||||
xxl: textTheme.titleMedium,
|
||||
);
|
||||
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
|
||||
final avatarWidth = useBreakpointValue(
|
||||
xs: mediaQuery.size.width * 0.50,
|
||||
sm: mediaQuery.size.width * 0.50,
|
||||
md: mediaQuery.size.width * 0.40,
|
||||
lg: mediaQuery.size.width * 0.18,
|
||||
xl: mediaQuery.size.width * 0.18,
|
||||
xxl: mediaQuery.size.width * 0.18,
|
||||
);
|
||||
|
||||
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
|
||||
final auth = ref.watch(AuthenticationNotifier.provider);
|
||||
|
||||
final queryClient = useQueryClient();
|
||||
final artistQuery = useQueries.artist.get(ref, artistId);
|
||||
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
child: Scaffold(
|
||||
appBar: const PageWindowTitleBar(
|
||||
leading: BackButton(),
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
body: HookBuilder(
|
||||
builder: (context) {
|
||||
final artistsQuery = useQueries.artist.get(ref, artistId);
|
||||
|
||||
if (artistsQuery.isLoading || !artistsQuery.hasData) {
|
||||
return const ShimmerArtistProfile();
|
||||
} else if (artistsQuery.hasError) {
|
||||
return Center(
|
||||
child: Text(artistsQuery.error.toString()),
|
||||
);
|
||||
extendBodyBehindAppBar: true,
|
||||
body: Builder(builder: (context) {
|
||||
if (artistQuery.isLoading || !artistQuery.hasData) {
|
||||
const ShimmerArtistProfile();
|
||||
} else if (artistQuery.hasError) {
|
||||
return Center(child: Text(artistQuery.error.toString()));
|
||||
}
|
||||
|
||||
final data = artistsQuery.data!;
|
||||
|
||||
final blacklist = ref.watch(BlackListNotifier.provider);
|
||||
final isBlackListed = blacklist.contains(
|
||||
BlacklistedElement.artist(artistId, data.name!),
|
||||
);
|
||||
|
||||
return InterScrollbar(
|
||||
controller: parentScrollController,
|
||||
child: SingleChildScrollView(
|
||||
controller: parentScrollController,
|
||||
return CustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
runAlignment: WrapAlignment.center,
|
||||
children: [
|
||||
const SizedBox(width: 50),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: CircleAvatar(
|
||||
radius: avatarWidth,
|
||||
backgroundImage: UniversalImage.imageProvider(
|
||||
TypeConversionUtils.image_X_UrlString(
|
||||
data.images,
|
||||
placeholder: ImagePlaceholder.artist,
|
||||
bottom: false,
|
||||
child: ArtistPageHeader(artistId: artistId),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isBlackListed) ...[
|
||||
const SizedBox(width: 5),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red[400],
|
||||
borderRadius:
|
||||
BorderRadius.circular(50)),
|
||||
child: Text(
|
||||
context.l10n.blacklisted,
|
||||
style: chipTextVariant.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
],
|
||||
),
|
||||
Text(
|
||||
data.name!,
|
||||
style: mediaQuery.smAndDown
|
||||
? textTheme.headlineSmall
|
||||
: textTheme.headlineMedium,
|
||||
),
|
||||
Text(
|
||||
context.l10n.followers(
|
||||
PrimitiveUtils.toReadableNumber(
|
||||
data.followers!.total!.toDouble(),
|
||||
),
|
||||
),
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: mediaQuery.mdAndUp
|
||||
? FontWeight.bold
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (auth != null)
|
||||
HookBuilder(
|
||||
builder: (context) {
|
||||
final isFollowingQuery = useQueries
|
||||
.artist
|
||||
.doIFollow(ref, artistId);
|
||||
|
||||
final followUnfollow =
|
||||
useCallback(() async {
|
||||
try {
|
||||
isFollowingQuery.data!
|
||||
? await spotify.me.unfollow(
|
||||
FollowingType.artist,
|
||||
[artistId],
|
||||
)
|
||||
: await spotify.me.follow(
|
||||
FollowingType.artist,
|
||||
[artistId],
|
||||
);
|
||||
await isFollowingQuery.refresh();
|
||||
|
||||
queryClient
|
||||
.refreshInfiniteQueryAllPages(
|
||||
"user-following-artists");
|
||||
} finally {
|
||||
queryClient.refreshQuery(
|
||||
"user-follows-artists-query/$artistId",
|
||||
);
|
||||
}
|
||||
}, [isFollowingQuery]);
|
||||
|
||||
if (isFollowingQuery.isLoading ||
|
||||
!isFollowingQuery.hasData) {
|
||||
return const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child:
|
||||
CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
if (isFollowingQuery.data!) {
|
||||
return OutlinedButton(
|
||||
onPressed: followUnfollow,
|
||||
child:
|
||||
Text(context.l10n.following),
|
||||
);
|
||||
}
|
||||
|
||||
return FilledButton(
|
||||
onPressed: followUnfollow,
|
||||
child: Text(context.l10n.follow),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
IconButton(
|
||||
tooltip:
|
||||
context.l10n.add_artist_to_blacklist,
|
||||
icon: Icon(
|
||||
SpotubeIcons.userRemove,
|
||||
color: !isBlackListed
|
||||
? Colors.red[400]
|
||||
: Colors.white,
|
||||
),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: isBlackListed
|
||||
? Colors.red[400]
|
||||
: null,
|
||||
),
|
||||
onPressed: () async {
|
||||
if (isBlackListed) {
|
||||
ref
|
||||
.read(BlackListNotifier
|
||||
.provider.notifier)
|
||||
.remove(
|
||||
BlacklistedElement.artist(
|
||||
data.id!, data.name!),
|
||||
);
|
||||
} else {
|
||||
ref
|
||||
.read(BlackListNotifier
|
||||
.provider.notifier)
|
||||
.add(
|
||||
BlacklistedElement.artist(
|
||||
data.id!, data.name!),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(SpotubeIcons.share),
|
||||
onPressed: () async {
|
||||
if (data.externalUrls?.spotify !=
|
||||
null) {
|
||||
await Clipboard.setData(
|
||||
ClipboardData(
|
||||
text: data.externalUrls!.spotify!,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
width: 300,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
content: Text(
|
||||
context.l10n.artist_url_copied,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 50),
|
||||
HookBuilder(
|
||||
builder: (context) {
|
||||
final topTracksQuery = useQueries.artist.topTracksOf(
|
||||
ref,
|
||||
artistId,
|
||||
);
|
||||
|
||||
final isPlaylistPlaying = playlist.containsTracks(
|
||||
topTracksQuery.data ?? <Track>[],
|
||||
);
|
||||
|
||||
if (topTracksQuery.isLoading ||
|
||||
!topTracksQuery.hasData) {
|
||||
return const CircularProgressIndicator();
|
||||
} else if (topTracksQuery.hasError) {
|
||||
return Center(
|
||||
child: Text(topTracksQuery.error.toString()),
|
||||
);
|
||||
}
|
||||
|
||||
final topTracks = topTracksQuery.data!;
|
||||
|
||||
void playPlaylist(List<Track> tracks,
|
||||
{Track? currentTrack}) async {
|
||||
currentTrack ??= tracks.first;
|
||||
if (!isPlaylistPlaying) {
|
||||
playlistNotifier.load(
|
||||
tracks,
|
||||
initialIndex: tracks.indexWhere(
|
||||
(s) => s.id == currentTrack?.id),
|
||||
autoPlay: true,
|
||||
);
|
||||
} else if (isPlaylistPlaying &&
|
||||
currentTrack.id != null &&
|
||||
currentTrack.id != playlist.activeTrack?.id) {
|
||||
await playlistNotifier.jumpToTrack(currentTrack);
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
context.l10n.top_tracks,
|
||||
style: theme.textTheme.headlineSmall,
|
||||
),
|
||||
),
|
||||
if (!isPlaylistPlaying)
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
SpotubeIcons.queueAdd,
|
||||
),
|
||||
onPressed: () {
|
||||
playlistNotifier
|
||||
.addTracks(topTracks.toList());
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
width: 300,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
content: Text(
|
||||
context.l10n.added_to_queue(
|
||||
topTracks.length,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
isPlaylistPlaying
|
||||
? SpotubeIcons.stop
|
||||
: SpotubeIcons.play,
|
||||
color: Colors.white,
|
||||
),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor:
|
||||
theme.colorScheme.primary,
|
||||
),
|
||||
onPressed: () =>
|
||||
playPlaylist(topTracks.toList()),
|
||||
)
|
||||
],
|
||||
),
|
||||
...topTracks.mapIndexed((i, track) {
|
||||
return TrackTile(
|
||||
index: i,
|
||||
track: track,
|
||||
onTap: () async {
|
||||
playPlaylist(
|
||||
topTracks.toList(),
|
||||
currentTrack: track,
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 50),
|
||||
ArtistAlbumList(artistId),
|
||||
const SizedBox(height: 20),
|
||||
Padding(
|
||||
const SliverGap(50),
|
||||
ArtistPageTopTracks(artistId: artistId),
|
||||
const SliverGap(50),
|
||||
SliverToBoxAdapter(child: ArtistAlbumList(artistId)),
|
||||
const SliverGap(20),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Text(
|
||||
context.l10n.fans_also_like,
|
||||
style: theme.textTheme.headlineSmall,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
HookBuilder(
|
||||
builder: (context) {
|
||||
final relatedArtists =
|
||||
useQueries.artist.relatedArtistsOf(
|
||||
ref,
|
||||
artistId,
|
||||
);
|
||||
|
||||
if (relatedArtists.isLoading ||
|
||||
!relatedArtists.hasData) {
|
||||
return const CircularProgressIndicator();
|
||||
} else if (relatedArtists.hasError) {
|
||||
return Center(
|
||||
child: Text(relatedArtists.error.toString()),
|
||||
);
|
||||
}
|
||||
|
||||
return Center(
|
||||
child: Wrap(
|
||||
spacing: 20,
|
||||
runSpacing: 20,
|
||||
children: relatedArtists.data!
|
||||
.map((artist) => ArtistCard(artist))
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
SliverSafeArea(
|
||||
sliver: ArtistPageRelatedArtists(artistId: artistId),
|
||||
),
|
||||
if (artistQuery.data != null)
|
||||
SliverSafeArea(
|
||||
top: false,
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: ArtistPageFooter(artist: artistQuery.data!),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
93
lib/pages/artist/section/footer.dart
Normal file
93
lib/pages/artist/section/footer.dart
Normal file
@ -0,0 +1,93 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class ArtistPageFooter extends HookConsumerWidget {
|
||||
final Artist artist;
|
||||
const ArtistPageFooter({Key? key, required this.artist}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final ThemeData(:textTheme) = Theme.of(context);
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
|
||||
final artistImage = TypeConversionUtils.image_X_UrlString(
|
||||
artist.images,
|
||||
placeholder: ImagePlaceholder.artist,
|
||||
);
|
||||
final summary = useQueries.artist.wikipediaSummary(artist);
|
||||
if (summary.hasError || !summary.hasData) return const SizedBox.shrink();
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: mediaQuery.smAndDown
|
||||
? const EdgeInsets.all(20)
|
||||
: const EdgeInsets.all(30),
|
||||
constraints: const BoxConstraints(minHeight: 300),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
image: DecorationImage(
|
||||
colorFilter: ColorFilter.mode(
|
||||
Colors.black.withOpacity(0.5),
|
||||
BlendMode.darken,
|
||||
),
|
||||
image: UniversalImage.imageProvider(
|
||||
summary.data!.originalimage?.source_ ?? artistImage,
|
||||
height: summary.data!.originalimage?.height.toDouble(),
|
||||
width: summary.data!.originalimage?.width.toDouble(),
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
style: textTheme.bodyLarge?.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
children: [
|
||||
// icon
|
||||
const WidgetSpan(
|
||||
child: Icon(
|
||||
SpotubeIcons.wikipedia,
|
||||
color: Colors.white,
|
||||
size: 30,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: " Wikipedia",
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const TextSpan(text: '\n\n'),
|
||||
TextSpan(
|
||||
text: summary.data!.extract,
|
||||
),
|
||||
TextSpan(
|
||||
text: '\n...read more at wikipedia',
|
||||
style: textTheme.bodyLarge?.copyWith(
|
||||
color: Colors.lightBlue[300],
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: Colors.lightBlue[300],
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () async {
|
||||
await launchUrlString(
|
||||
"http://en.wikipedia.org/wiki?curid=${summary.data?.pageid}",
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
257
lib/pages/artist/section/header.dart
Normal file
257
lib/pages/artist/section/header.dart
Normal file
@ -0,0 +1,257 @@
|
||||
import 'package:fl_query_hooks/fl_query_hooks.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/hooks/utils/use_breakpoint_value.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/provider/blacklist_provider.dart';
|
||||
import 'package:spotube/provider/spotify_provider.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
import 'package:spotube/utils/primitive_utils.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
class ArtistPageHeader extends HookConsumerWidget {
|
||||
final String artistId;
|
||||
const ArtistPageHeader({Key? key, required this.artistId}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final queryClient = useQueryClient();
|
||||
final artistQuery = useQueries.artist.get(ref, artistId);
|
||||
final artist = artistQuery.data;
|
||||
|
||||
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final ThemeData(:textTheme) = theme;
|
||||
|
||||
final chipTextVariant = useBreakpointValue(
|
||||
xs: textTheme.bodySmall,
|
||||
sm: textTheme.bodySmall,
|
||||
md: textTheme.bodyMedium,
|
||||
lg: textTheme.bodyLarge,
|
||||
xl: textTheme.titleSmall,
|
||||
xxl: textTheme.titleMedium,
|
||||
);
|
||||
|
||||
if (artist == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final spotify = ref.read(spotifyProvider);
|
||||
final auth = ref.watch(AuthenticationNotifier.provider);
|
||||
final blacklist = ref.watch(BlackListNotifier.provider);
|
||||
final isBlackListed = blacklist.contains(
|
||||
BlacklistedElement.artist(artistId, artist.name!),
|
||||
);
|
||||
|
||||
final image = TypeConversionUtils.image_X_UrlString(
|
||||
artist.images,
|
||||
placeholder: ImagePlaceholder.artist,
|
||||
);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constrains) {
|
||||
return Center(
|
||||
child: Flex(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: constrains.smAndDown
|
||||
? CrossAxisAlignment.start
|
||||
: CrossAxisAlignment.center,
|
||||
direction: constrains.smAndDown ? Axis.vertical : Axis.horizontal,
|
||||
children: [
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
boxShadow: kElevationToShadow[2],
|
||||
borderRadius: BorderRadius.circular(35),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(35),
|
||||
child: UniversalImage(
|
||||
path: image,
|
||||
width: 250,
|
||||
height: 250,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Gap(20),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue,
|
||||
borderRadius: BorderRadius.circular(50)),
|
||||
child: Text(
|
||||
artist.type!.toUpperCase(),
|
||||
style: chipTextVariant.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isBlackListed) ...[
|
||||
const SizedBox(width: 5),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red[400],
|
||||
borderRadius: BorderRadius.circular(50)),
|
||||
child: Text(
|
||||
context.l10n.blacklisted,
|
||||
style: chipTextVariant.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
],
|
||||
),
|
||||
Text(
|
||||
artist.name!,
|
||||
style: mediaQuery.smAndDown
|
||||
? textTheme.headlineSmall
|
||||
: textTheme.headlineMedium,
|
||||
),
|
||||
Text(
|
||||
context.l10n.followers(
|
||||
PrimitiveUtils.toReadableNumber(
|
||||
artist.followers!.total!.toDouble(),
|
||||
),
|
||||
),
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: mediaQuery.mdAndUp ? FontWeight.bold : null,
|
||||
),
|
||||
),
|
||||
const Gap(20),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (auth != null)
|
||||
HookBuilder(
|
||||
builder: (context) {
|
||||
final isFollowingQuery =
|
||||
useQueries.artist.doIFollow(ref, artistId);
|
||||
|
||||
final followUnfollow = useCallback(() async {
|
||||
try {
|
||||
isFollowingQuery.data!
|
||||
? await spotify.me.unfollow(
|
||||
FollowingType.artist,
|
||||
[artistId],
|
||||
)
|
||||
: await spotify.me.follow(
|
||||
FollowingType.artist,
|
||||
[artistId],
|
||||
);
|
||||
await isFollowingQuery.refresh();
|
||||
|
||||
queryClient.refreshInfiniteQueryAllPages(
|
||||
"user-following-artists");
|
||||
} finally {
|
||||
queryClient.refreshQuery(
|
||||
"user-follows-artists-query/$artistId",
|
||||
);
|
||||
}
|
||||
}, [isFollowingQuery]);
|
||||
|
||||
if (isFollowingQuery.isLoading ||
|
||||
!isFollowingQuery.hasData) {
|
||||
return const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
if (isFollowingQuery.data!) {
|
||||
return OutlinedButton(
|
||||
onPressed: followUnfollow,
|
||||
child: Text(context.l10n.following),
|
||||
);
|
||||
}
|
||||
|
||||
return FilledButton(
|
||||
onPressed: followUnfollow,
|
||||
child: Text(context.l10n.follow),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
IconButton(
|
||||
tooltip: context.l10n.add_artist_to_blacklist,
|
||||
icon: Icon(
|
||||
SpotubeIcons.userRemove,
|
||||
color:
|
||||
!isBlackListed ? Colors.red[400] : Colors.white,
|
||||
),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor:
|
||||
isBlackListed ? Colors.red[400] : null,
|
||||
),
|
||||
onPressed: () async {
|
||||
if (isBlackListed) {
|
||||
ref
|
||||
.read(BlackListNotifier.provider.notifier)
|
||||
.remove(
|
||||
BlacklistedElement.artist(
|
||||
artist.id!, artist.name!),
|
||||
);
|
||||
} else {
|
||||
ref.read(BlackListNotifier.provider.notifier).add(
|
||||
BlacklistedElement.artist(
|
||||
artist.id!, artist.name!),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(SpotubeIcons.share),
|
||||
onPressed: () async {
|
||||
if (artist.externalUrls?.spotify != null) {
|
||||
await Clipboard.setData(
|
||||
ClipboardData(
|
||||
text: artist.externalUrls!.spotify!,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
width: 300,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
content: Text(
|
||||
context.l10n.artist_url_copied,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
49
lib/pages/artist/section/related_artists.dart
Normal file
49
lib/pages/artist/section/related_artists.dart
Normal file
@ -0,0 +1,49 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotube/components/artist/artist_card.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
|
||||
class ArtistPageRelatedArtists extends HookConsumerWidget {
|
||||
final String artistId;
|
||||
const ArtistPageRelatedArtists({
|
||||
Key? key,
|
||||
required this.artistId,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final relatedArtists = useQueries.artist.relatedArtistsOf(
|
||||
ref,
|
||||
artistId,
|
||||
);
|
||||
|
||||
if (relatedArtists.isLoading || !relatedArtists.hasData) {
|
||||
return const SliverToBoxAdapter(
|
||||
child: Center(child: CircularProgressIndicator()));
|
||||
} else if (relatedArtists.hasError) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: Text(relatedArtists.error.toString()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
sliver: SliverGrid.builder(
|
||||
itemCount: relatedArtists.data!.length,
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 200,
|
||||
mainAxisExtent: 250,
|
||||
mainAxisSpacing: 10,
|
||||
crossAxisSpacing: 10,
|
||||
childAspectRatio: 0.8,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final artist = relatedArtists.data!.elementAt(index);
|
||||
return ArtistCard(artist);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
126
lib/pages/artist/section/top_tracks.dart
Normal file
126
lib/pages/artist/section/top_tracks.dart
Normal file
@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/shared/track_tile/track_tile.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
|
||||
class ArtistPageTopTracks extends HookConsumerWidget {
|
||||
final String artistId;
|
||||
const ArtistPageTopTracks({Key? key, required this.artistId})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final theme = Theme.of(context);
|
||||
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
||||
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||
final topTracksQuery = useQueries.artist.topTracksOf(
|
||||
ref,
|
||||
artistId,
|
||||
);
|
||||
|
||||
final isPlaylistPlaying = playlist.containsTracks(
|
||||
topTracksQuery.data ?? <Track>[],
|
||||
);
|
||||
|
||||
if (topTracksQuery.isLoading || !topTracksQuery.hasData) {
|
||||
return const SliverToBoxAdapter(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
} else if (topTracksQuery.hasError) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: Text(topTracksQuery.error.toString()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final topTracks = topTracksQuery.data!;
|
||||
|
||||
void playPlaylist(List<Track> tracks, {Track? currentTrack}) async {
|
||||
currentTrack ??= tracks.first;
|
||||
if (!isPlaylistPlaying) {
|
||||
playlistNotifier.load(
|
||||
tracks,
|
||||
initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id),
|
||||
autoPlay: true,
|
||||
);
|
||||
} else if (isPlaylistPlaying &&
|
||||
currentTrack.id != null &&
|
||||
currentTrack.id != playlist.activeTrack?.id) {
|
||||
await playlistNotifier.jumpToTrack(currentTrack);
|
||||
}
|
||||
}
|
||||
|
||||
return SliverMainAxisGroup(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
context.l10n.top_tracks,
|
||||
style: theme.textTheme.headlineSmall,
|
||||
),
|
||||
),
|
||||
if (!isPlaylistPlaying)
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
SpotubeIcons.queueAdd,
|
||||
),
|
||||
onPressed: () {
|
||||
playlistNotifier.addTracks(topTracks.toList());
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
width: 300,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
content: Text(
|
||||
context.l10n.added_to_queue(
|
||||
topTracks.length,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
isPlaylistPlaying ? SpotubeIcons.stop : SpotubeIcons.play,
|
||||
color: Colors.white,
|
||||
),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
),
|
||||
onPressed: () => playPlaylist(topTracks.toList()),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
SliverList.builder(
|
||||
itemCount: topTracks.length,
|
||||
itemBuilder: (context, index) {
|
||||
final track = topTracks.elementAt(index);
|
||||
return TrackTile(
|
||||
index: index,
|
||||
track: track,
|
||||
onTap: () async {
|
||||
playPlaylist(
|
||||
topTracks.toList(),
|
||||
currentTrack: track,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -1,8 +1,12 @@
|
||||
import 'package:fl_query/fl_query.dart';
|
||||
import 'package:fl_query_hooks/fl_query_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/hooks/spotify/use_spotify_infinite_query.dart';
|
||||
import 'package:spotube/hooks/spotify/use_spotify_query.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||
import 'package:spotube/services/wikipedia/wikipedia.dart';
|
||||
import 'package:wikipedia_api/wikipedia_api.dart';
|
||||
|
||||
class ArtistQueries {
|
||||
const ArtistQueries();
|
||||
@ -72,11 +76,11 @@ class ArtistQueries {
|
||||
return useSpotifyQuery<bool, dynamic>(
|
||||
"user-follows-artists-query/$artist",
|
||||
(spotify) async {
|
||||
final result = await spotify.me.isFollowing(
|
||||
final result = await spotify.me.checkFollowing(
|
||||
FollowingType.artist,
|
||||
[artist],
|
||||
);
|
||||
return result.first;
|
||||
return result[artist];
|
||||
},
|
||||
ref: ref,
|
||||
);
|
||||
@ -86,10 +90,12 @@ class ArtistQueries {
|
||||
WidgetRef ref,
|
||||
String artist,
|
||||
) {
|
||||
final preferences = ref.watch(userPreferencesProvider);
|
||||
return useSpotifyQuery<Iterable<Track>, dynamic>(
|
||||
"artist-top-track-query/$artist",
|
||||
(spotify) {
|
||||
return spotify.artists.getTopTracks(artist, "US");
|
||||
return spotify.artists
|
||||
.topTracks(artist, preferences.recommendationMarket);
|
||||
},
|
||||
ref: ref,
|
||||
);
|
||||
@ -122,9 +128,24 @@ class ArtistQueries {
|
||||
return useSpotifyQuery<Iterable<Artist>, dynamic>(
|
||||
"artist-related-artist-query/$artist",
|
||||
(spotify) {
|
||||
return spotify.artists.getRelatedArtists(artist);
|
||||
return spotify.artists.relatedArtists(artist);
|
||||
},
|
||||
ref: ref,
|
||||
);
|
||||
}
|
||||
|
||||
Query<Summary?, dynamic> wikipediaSummary(ArtistSimple artist) {
|
||||
return useQuery<Summary?, dynamic>(
|
||||
"artist-wikipedia-query/${artist.id}",
|
||||
() async {
|
||||
final query = artist.name!.replaceAll(" ", "_");
|
||||
final res = await wikipedia.pageContent.pageSummaryTitleGet(query);
|
||||
if (res?.type != "standard") {
|
||||
return await wikipedia.pageContent
|
||||
.pageSummaryTitleGet("${query}_(singer)");
|
||||
}
|
||||
return res;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
3
lib/services/wikipedia/wikipedia.dart
Normal file
3
lib/services/wikipedia/wikipedia.dart
Normal file
@ -0,0 +1,3 @@
|
||||
import 'package:wikipedia_api/wikipedia_api.dart';
|
||||
|
||||
final wikipedia = WikipediaApi();
|
11
pubspec.lock
11
pubspec.lock
@ -2222,6 +2222,15 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
wikipedia_api:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: HEAD
|
||||
resolved-ref: cb7590a3d76b25f16ad3f7147ae6603350777a00
|
||||
url: "https://github.com/KRTirtho/wikipedia_api.git"
|
||||
source: git
|
||||
version: "0.1.0"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -2288,5 +2297,5 @@ packages:
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
sdks:
|
||||
dart: ">=3.2.0-194.0.dev <4.0.0"
|
||||
dart: ">=3.2.0 <4.0.0"
|
||||
flutter: ">=3.13.0"
|
||||
|
@ -119,6 +119,9 @@ dependencies:
|
||||
git:
|
||||
url: https://github.com/Tommypop2/dart_discord_rpc.git
|
||||
html_unescape: ^2.0.0
|
||||
wikipedia_api:
|
||||
git:
|
||||
url: https://github.com/KRTirtho/wikipedia_api.git
|
||||
|
||||
dev_dependencies:
|
||||
build_runner: ^2.3.2
|
||||
|
Loading…
Reference in New Issue
Block a user