From 8d77b6900a81aab020e19397e788964b0ac499ff Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 19 Aug 2022 11:53:52 +0600 Subject: [PATCH] feat: add download tab on library Addition of download badge in sidebar and navbar library icon Fix SpotubeMarqueeText behavior using auto_size_text --- lib/components/Home/Home.dart | 2 - lib/components/Home/Sidebar.dart | 43 ++++-- lib/components/Home/SpotubeNavigationBar.dart | 31 +++- lib/components/Library/UserDownloads.dart | 83 +++++++++++ lib/components/Library/UserLibrary.dart | 5 +- lib/components/Shared/PlaybuttonCard.dart | 2 +- lib/components/Shared/SpotubeMarqueeText.dart | 58 +++----- lib/components/Shared/TrackTile.dart | 16 +- lib/components/Shared/TracksTableView.dart | 24 ++- lib/main.dart | 114 ++++++++++----- lib/provider/Downloader.dart | 138 ++++++++++++------ pubspec.lock | 14 ++ pubspec.yaml | 2 + 13 files changed, 376 insertions(+), 156 deletions(-) create mode 100644 lib/components/Library/UserDownloads.dart diff --git a/lib/components/Home/Home.dart b/lib/components/Home/Home.dart index 39b1333af..6084448ec 100644 --- a/lib/components/Home/Home.dart +++ b/lib/components/Home/Home.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:flutter/material.dart' hide Page; import 'package:flutter/services.dart'; diff --git a/lib/components/Home/Sidebar.dart b/lib/components/Home/Sidebar.dart index 87f2e3412..df792eab0 100644 --- a/lib/components/Home/Sidebar.dart +++ b/lib/components/Home/Sidebar.dart @@ -1,3 +1,4 @@ +import 'package:badges/badges.dart'; import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -8,6 +9,7 @@ import 'package:spotube/hooks/useBreakpointValue.dart'; import 'package:spotube/hooks/useBreakpoints.dart'; import 'package:spotube/models/sideBarTiles.dart'; import 'package:spotube/provider/Auth.dart'; +import 'package:spotube/provider/Downloader.dart'; import 'package:spotube/provider/SpotifyRequests.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; @@ -40,6 +42,9 @@ class Sidebar extends HookConsumerWidget { final extended = useState(false); final meSnapshot = ref.watch(currentUserQuery); final auth = ref.watch(authProvider); + final downloadCount = ref.watch( + downloaderProvider.select((s) => s.currentlyRunning), + ); final int titleBarDragMaxWidth = useBreakpointValue( md: 80, @@ -90,20 +95,34 @@ class Sidebar extends HookConsumerWidget { ), Expanded( child: NavigationRail( - destinations: sidebarTileList - .map( - (e) => NavigationRailDestination( - icon: Icon(e.icon), - label: Text( - e.title, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), + destinations: sidebarTileList.map( + (e) { + final icon = Icon(e.icon); + return NavigationRailDestination( + icon: e.title == "Library" && downloadCount > 0 + ? Badge( + badgeColor: Colors.red[100]!, + badgeContent: Text( + downloadCount.toString(), + style: const TextStyle( + color: Colors.red, + fontWeight: FontWeight.bold, + ), + ), + animationType: BadgeAnimationType.fade, + child: icon, + ) + : icon, + label: Text( + e.title, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, ), ), - ) - .toList(), + ); + }, + ).toList(), selectedIndex: selectedIndex, onDestinationSelected: onSelectedIndexChanged, extended: extended.value, diff --git a/lib/components/Home/SpotubeNavigationBar.dart b/lib/components/Home/SpotubeNavigationBar.dart index 158aad5c6..562e3b40e 100644 --- a/lib/components/Home/SpotubeNavigationBar.dart +++ b/lib/components/Home/SpotubeNavigationBar.dart @@ -1,10 +1,13 @@ +import 'package:badges/badges.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/components/Home/Sidebar.dart'; import 'package:spotube/hooks/useBreakpoints.dart'; import 'package:spotube/models/sideBarTiles.dart'; +import 'package:spotube/provider/Downloader.dart'; -class SpotubeNavigationBar extends HookWidget { +class SpotubeNavigationBar extends HookConsumerWidget { final int selectedIndex; final void Function(int) onSelectedIndexChanged; @@ -15,14 +18,36 @@ class SpotubeNavigationBar extends HookWidget { }) : super(key: key); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, ref) { + final downloadCount = ref.watch( + downloaderProvider.select((s) => s.currentlyRunning), + ); final breakpoint = useBreakpoints(); if (breakpoint.isMoreThan(Breakpoints.sm)) return Container(); return NavigationBar( destinations: [ ...sidebarTileList.map( - (e) => NavigationDestination(icon: Icon(e.icon), label: e.title), + (e) { + final icon = Icon(e.icon); + return NavigationDestination( + icon: e.title == "Library" && downloadCount > 0 + ? Badge( + badgeColor: Colors.red[100]!, + badgeContent: Text( + downloadCount.toString(), + style: const TextStyle( + color: Colors.red, + fontWeight: FontWeight.bold, + ), + ), + animationType: BadgeAnimationType.fade, + child: icon, + ) + : icon, + label: e.title, + ); + }, ), const NavigationDestination( icon: Icon(Icons.settings_rounded), diff --git a/lib/components/Library/UserDownloads.dart b/lib/components/Library/UserDownloads.dart new file mode 100644 index 000000000..ab7f56233 --- /dev/null +++ b/lib/components/Library/UserDownloads.dart @@ -0,0 +1,83 @@ +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/provider/Downloader.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; + +class UserDownloads extends HookConsumerWidget { + const UserDownloads({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final downloader = ref.watch(downloaderProvider); + + final inQueue = downloader.inQueue.toList(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 15), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: AutoSizeText( + "Currently downloading (${downloader.currentlyRunning})", + maxLines: 1, + style: Theme.of(context).textTheme.headline5, + ), + ), + const SizedBox(width: 10), + ElevatedButton( + style: ElevatedButton.styleFrom( + primary: Colors.red[50], + onPrimary: Colors.red[400], + ), + child: const Text("Cancel All"), + onPressed: downloader.currentlyRunning > 0 + ? downloader.cancelAll + : null, + ), + ], + ), + ), + ListView.builder( + itemCount: inQueue.length, + shrinkWrap: true, + itemBuilder: (context, index) { + final track = inQueue[index]; + return ListTile( + title: Text(track.name!), + leading: Padding( + padding: const EdgeInsets.symmetric(horizontal: 5), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: CachedNetworkImage( + height: 40, + width: 40, + imageUrl: TypeConversionUtils.image_X_UrlString( + track.album?.images, + ), + ), + ), + ), + trailing: const SizedBox( + width: 30, + height: 30, + child: CircularProgressIndicator.adaptive(), + ), + horizontalTitleGap: 5, + subtitle: Text( + TypeConversionUtils.artists_X_String( + track.artists ?? [], + ), + ), + ); + }, + ), + ], + ); + } +} diff --git a/lib/components/Library/UserLibrary.dart b/lib/components/Library/UserLibrary.dart index 0132e8f2d..43f8bc5ff 100644 --- a/lib/components/Library/UserLibrary.dart +++ b/lib/components/Library/UserLibrary.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart' hide Image; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotube/components/Library/UserAlbums.dart'; import 'package:spotube/components/Library/UserArtists.dart'; +import 'package:spotube/components/Library/UserDownloads.dart'; import 'package:spotube/components/Library/UserPlaylists.dart'; import 'package:spotube/components/Shared/AnonymousFallback.dart'; import 'package:spotube/provider/Auth.dart'; @@ -14,7 +15,7 @@ class UserLibrary extends ConsumerWidget { return Expanded( child: DefaultTabController( - length: 3, + length: 4, child: SafeArea( child: Scaffold( appBar: TabBar( @@ -26,6 +27,7 @@ class UserLibrary extends ConsumerWidget { Tab(text: "Playlist"), Tab(text: "Artists"), Tab(text: "Album"), + Tab(text: "Downloads"), ], ), body: auth.isLoggedIn @@ -33,6 +35,7 @@ class UserLibrary extends ConsumerWidget { const UserPlaylists(), UserArtists(), const UserAlbums(), + const UserDownloads(), ]) : const AnonymousFallback(), ), diff --git a/lib/components/Shared/PlaybuttonCard.dart b/lib/components/Shared/PlaybuttonCard.dart index 06eb305fe..922e8464e 100644 --- a/lib/components/Shared/PlaybuttonCard.dart +++ b/lib/components/Shared/PlaybuttonCard.dart @@ -106,7 +106,7 @@ class PlaybuttonCard extends StatelessWidget { text: title, style: const TextStyle(fontWeight: FontWeight.bold), - minStartLength: 25, + minStartLength: 20, isHovering: isHovering, ), ), diff --git a/lib/components/Shared/SpotubeMarqueeText.dart b/lib/components/Shared/SpotubeMarqueeText.dart index 8b2e82719..40159d998 100644 --- a/lib/components/Shared/SpotubeMarqueeText.dart +++ b/lib/components/Shared/SpotubeMarqueeText.dart @@ -1,7 +1,7 @@ +import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:marquee/marquee.dart'; -import 'package:spotube/utils/platform.dart'; class SpotubeMarqueeText extends HookWidget { final int? minStartLength; @@ -18,46 +18,32 @@ class SpotubeMarqueeText extends HookWidget { @override Widget build(BuildContext context) { - final hovering = useState(false); - final isInitial = useState(true); + final uKey = useState(UniqueKey()); useEffect(() { - if (isHovering != null && isHovering != hovering.value) { - hovering.value = isHovering!; - } - return null; + uKey.value = UniqueKey(); + return; }, [isHovering]); - if ((!isInitial.value && !hovering.value && kIsDesktop) || - minStartLength != null && text.length <= minStartLength!) { - return Text( - text, - style: style, - overflow: TextOverflow.ellipsis, - ); - } - - return Marquee( - text: text, + return AutoSizeText( + text, + minFontSize: 13, style: style, - scrollAxis: Axis.horizontal, - crossAxisAlignment: CrossAxisAlignment.start, - blankSpace: 40.0, - velocity: 30.0, - accelerationDuration: const Duration(seconds: 1), - accelerationCurve: Curves.linear, - decelerationDuration: const Duration(milliseconds: 500), - decelerationCurve: Curves.easeOut, - fadingEdgeStartFraction: 0.15, - fadingEdgeEndFraction: 0.15, - showFadingOnlyWhenScrolling: true, - onDone: () { - if (isInitial.value) { - isInitial.value = false; - hovering.value = false; - } - }, - numberOfRounds: hovering.value ? null : 1, + overflowReplacement: Marquee( + key: uKey.value, + text: text, + style: style, + scrollAxis: Axis.horizontal, + crossAxisAlignment: CrossAxisAlignment.start, + blankSpace: 40.0, + velocity: 30.0, + accelerationDuration: const Duration(seconds: 1), + accelerationCurve: Curves.linear, + decelerationDuration: const Duration(milliseconds: 500), + decelerationCurve: Curves.easeOut, + showFadingOnlyWhenScrolling: true, + numberOfRounds: isHovering == true ? null : 1, + ), ); } } diff --git a/lib/components/Shared/TrackTile.dart b/lib/components/Shared/TrackTile.dart index b8b9b2f9c..d5ef5a640 100644 --- a/lib/components/Shared/TrackTile.dart +++ b/lib/components/Shared/TrackTile.dart @@ -193,15 +193,15 @@ class TrackTile extends HookConsumerWidget { Checkbox( value: isChecked, onChanged: (s) => onCheckChange?.call(s), + ) + else + SizedBox( + height: 20, + width: 25, + child: Center( + child: Text((track.key + 1).toString()), + ), ), - SizedBox( - height: 20, - width: 15, - child: Text( - (track.key + 1).toString(), - textAlign: TextAlign.center, - ), - ), if (thumbnailUrl != null) Padding( padding: EdgeInsets.symmetric( diff --git a/lib/components/Shared/TracksTableView.dart b/lib/components/Shared/TracksTableView.dart index e010a91fb..d38f69815 100644 --- a/lib/components/Shared/TracksTableView.dart +++ b/lib/components/Shared/TracksTableView.dart @@ -132,23 +132,11 @@ class TracksTableView extends HookConsumerWidget { return const DownloadConfirmationDialog(); }); if (isConfirmed != true) return; - final queue = Queue( - delay: const Duration(seconds: 5), - ); for (final selectedTrack in selectedTracks) { - queue.add(() async { - downloader.addToQueue( - await playback.toSpotubeTrack( - selectedTrack, - noSponsorBlock: true, - ), - ); - }); + downloader.addToQueue(selectedTrack); } - selected.value = []; showCheck.value = false; - await queue.onComplete; break; } default: @@ -171,7 +159,15 @@ class TracksTableView extends HookConsumerWidget { }, onTap: () { if (showCheck.value) { - selected.value = [...selected.value, track.value.id!]; + final alreadyChecked = + selected.value.contains(track.value.id); + if (alreadyChecked) { + selected.value = selected.value + .where((id) => id != track.value.id) + .toList(); + } else { + selected.value = [...selected.value, track.value.id!]; + } } else { onTrackPlayButtonPressed?.call(track.value); } diff --git a/lib/main.dart b/lib/main.dart index fdd3cb8a0..fa5a87773 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,11 +10,13 @@ import 'package:go_router/go_router.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotube/components/Shared/DownloadTrackButton.dart'; import 'package:spotube/entities/CacheTrack.dart'; import 'package:spotube/models/GoRouteDeclarations.dart'; import 'package:spotube/models/LocalStorageKeys.dart'; import 'package:spotube/models/Logger.dart'; import 'package:spotube/provider/AudioPlayer.dart'; +import 'package:spotube/provider/Downloader.dart'; import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/UserPreferences.dart'; import 'package:spotube/provider/YouTube.dart'; @@ -53,41 +55,85 @@ void main() async { ); } MobileAudioService? audioServiceHandler; - runApp(ProviderScope( - child: const Spotube(), - overrides: [ - playbackProvider.overrideWithProvider(ChangeNotifierProvider( - (ref) { - final youtube = ref.watch(youtubeProvider); - final player = ref.watch(audioPlayerProvider); - - final playback = Playback( - player: player, - youtube: youtube, - ref: ref, - ); - - if (audioServiceHandler == null) { - AudioService.init( - builder: () => MobileAudioService(playback), - config: const AudioServiceConfig( - androidNotificationChannelId: 'com.krtirtho.Spotube', - androidNotificationChannelName: 'Spotube', - androidNotificationOngoing: true, + runApp( + Builder( + builder: (context) { + return ProviderScope( + child: const Spotube(), + overrides: [ + playbackProvider.overrideWithProvider( + ChangeNotifierProvider( + (ref) { + final youtube = ref.watch(youtubeProvider); + final player = ref.watch(audioPlayerProvider); + + final playback = Playback( + player: player, + youtube: youtube, + ref: ref, + ); + + if (audioServiceHandler == null) { + AudioService.init( + builder: () => MobileAudioService(playback), + config: const AudioServiceConfig( + androidNotificationChannelId: 'com.krtirtho.Spotube', + androidNotificationChannelName: 'Spotube', + androidNotificationOngoing: true, + ), + ).then( + (value) { + playback.mobileAudioService = value; + audioServiceHandler = value; + }, + ); + } + + return playback; + }, ), - ).then( - (value) { - playback.mobileAudioService = value; - audioServiceHandler = value; - }, - ); - } - - return playback; - }, - )) - ], - )); + ), + downloaderProvider.overrideWithProvider( + ChangeNotifierProvider( + (ref) { + return Downloader( + ref, + queueInstance, + yt: ref.watch(youtubeProvider), + downloadPath: ref.watch( + userPreferencesProvider.select( + (s) => s.downloadLocation, + ), + ), + onFileExists: (track) { + final logger = getLogger(Downloader); + try { + logger.v( + "[onFileExists] download confirmation for ${track.name}", + ); + return showDialog( + context: context, + builder: (_) => + ReplaceDownloadedFileDialog(track: track), + ).then((s) => s ?? false); + } catch (e, stack) { + logger.e( + "onFileExists", + e, + stack, + ); + return false; + } + }, + ); + }, + ), + ) + ], + ); + }, + ), + ); } class Spotube extends StatefulHookConsumerWidget { diff --git a/lib/provider/Downloader.dart b/lib/provider/Downloader.dart index 8a4c72225..2c9bf522b 100644 --- a/lib/provider/Downloader.dart +++ b/lib/provider/Downloader.dart @@ -6,20 +6,26 @@ import 'package:flutter_downloader/flutter_downloader.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:queue/queue.dart'; import 'package:path/path.dart' as path; +import 'package:spotify/spotify.dart'; +import 'package:spotube/models/Logger.dart'; import 'package:spotube/models/SpotubeTrack.dart'; +import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/UserPreferences.dart'; import 'package:spotube/provider/YouTube.dart'; import 'package:spotube/utils/platform.dart'; import 'package:youtube_explode_dart/youtube_explode_dart.dart'; -Queue _queueInstance = Queue(delay: const Duration(seconds: 5)); +Queue queueInstance = Queue(delay: const Duration(seconds: 5)); +Queue grabberQueue = Queue(delay: const Duration(seconds: 5)); class Downloader with ChangeNotifier { + Ref ref; Queue _queue; YoutubeExplode yt; String downloadPath; FutureOr Function(SpotubeTrack track)? onFileExists; Downloader( + this.ref, this._queue, { required this.downloadPath, required this.yt, @@ -27,73 +33,115 @@ class Downloader with ChangeNotifier { }); int currentlyRunning = 0; - Set inQueue = {}; + // ignore: prefer_collection_literals + Set inQueue = Set(); - void addToQueue(SpotubeTrack track) async { + final logger = getLogger(Downloader); + + void addToQueue(Track baseTrack) async { + if (inQueue.any((t) => t.id == baseTrack.id!)) return; + inQueue.add(baseTrack); currentlyRunning++; - inQueue.add(track.id!); notifyListeners(); - final filename = '${track.ytTrack.title}.mp3'; if (kIsMobile) { - final url = - ((await yt.videos.streamsClient.getManifest(track.ytTrack.url))) - .audioOnly - .where((audio) => audio.codec.mimeType == "audio/mp4") - .withHighestBitrate() - .url; - await FlutterDownloader.enqueue( - savedDir: downloadPath, - url: url.toString(), - fileName: filename, - openFileFromNotification: true, - showNotification: true, - ); + grabberQueue.add(() async { + final track = await ref.read(playbackProvider).toSpotubeTrack( + baseTrack, + noSponsorBlock: true, + ); + + final filename = '${track.ytTrack.title}.mp3'; + + final url = + ((await yt.videos.streamsClient.getManifest(track.ytTrack.url))) + .audioOnly + .where((audio) => audio.codec.mimeType == "audio/mp4") + .withHighestBitrate() + .url; + await FlutterDownloader.enqueue( + savedDir: downloadPath, + url: url.toString(), + fileName: filename, + openFileFromNotification: true, + showNotification: true, + ); + }); } else { - if (inQueue.contains(track.id!)) return; - _queue.add(() async { - try { + grabberQueue.add(() async { + final track = await ref.read(playbackProvider).toSpotubeTrack( + baseTrack, + noSponsorBlock: true, + ); + _queue.add(() async { + final filename = '${track.ytTrack.title}.mp3'; final file = File(path.join(downloadPath, filename)); - if (file.existsSync() && await onFileExists?.call(track) != true) { - return; - } - file.createSync(recursive: true); - StreamManifest manifest = - await yt.videos.streamsClient.getManifest(track.ytTrack.url); - final audioStream = yt.videos.streamsClient - .get( - manifest.audioOnly - .where((audio) => audio.codec.mimeType == "audio/mp4") - .withHighestBitrate(), - ) - .asBroadcastStream(); + try { + logger.v("[addToQueue] Download starting for ${file.path}"); + if (file.existsSync() && await onFileExists?.call(track) != true) { + return; + } + file.createSync(recursive: true); + StreamManifest manifest = + await yt.videos.streamsClient.getManifest(track.ytTrack.url); + logger.v( + "[addToQueue] Getting download information for ${file.path}", + ); + final audioStream = yt.videos.streamsClient + .get( + manifest.audioOnly + .where((audio) => audio.codec.mimeType == "audio/mp4") + .withHighestBitrate(), + ) + .asBroadcastStream(); + + logger.v( + "[addToQueue] ${file.path} download started", + ); - IOSink outputFileStream = file.openWrite(); - await audioStream.pipe(outputFileStream); - await outputFileStream.flush(); - } finally { - currentlyRunning--; - inQueue.remove(track.id); - notifyListeners(); - } + IOSink outputFileStream = file.openWrite(); + await audioStream.pipe(outputFileStream); + await outputFileStream.flush(); + logger.v( + "[addToQueue] Download of ${file.path} is done successfully", + ); + } catch (e, stack) { + logger.e( + "[addToQueue] Failed download of ${file.path}", + e, + stack, + ); + rethrow; + } finally { + currentlyRunning--; + inQueue.removeWhere((t) => t.id == track.id); + notifyListeners(); + } + }); }); } } cancelAll() { + grabberQueue.cancel(); + grabberQueue = Queue(); + inQueue.clear(); + currentlyRunning = 0; if (kIsMobile) { FlutterDownloader.cancelAll(); } else { _queue.cancel(); - _queueInstance = Queue(); - _queue = _queueInstance; + queueInstance = Queue(); + _queue = queueInstance; } + notifyListeners(); } } final downloaderProvider = ChangeNotifierProvider( (ref) { return Downloader( - _queueInstance, + ref, + queueInstance, yt: ref.watch(youtubeProvider), downloadPath: ref.watch( userPreferencesProvider.select( diff --git a/pubspec.lock b/pubspec.lock index 7576f2fc8..5861dd44f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -218,6 +218,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.0" + auto_size_text: + dependency: "direct main" + description: + name: auto_size_text + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + badges: + dependency: "direct main" + description: + name: badges + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.3" bitsdojo_window: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index ff4857070..7a341e829 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -69,6 +69,8 @@ dependencies: popover: ^0.2.6+3 queue: ^3.1.0+1 flutter_downloader: ^1.8.1 + auto_size_text: ^3.0.0 + badges: ^2.0.3 dev_dependencies: flutter_test: