From f0b6d660e2e24d3ffd139b7e3e551d46aee8056c Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 9 May 2024 23:44:43 +0600 Subject: [PATCH] feat: add individual minutes and streams page --- lib/collections/routes.dart | 18 +++++ lib/components/stats/common/album_item.dart | 53 +++++++++++++ lib/components/stats/common/artist_item.dart | 39 ++++++++++ lib/components/stats/common/track_item.dart | 49 ++++++++++++ lib/components/stats/summary/summary.dart | 9 +++ .../stats/summary/summary_card.dart | 75 ++++++++++--------- lib/components/stats/top/albums.dart | 46 +----------- lib/components/stats/top/artists.dart | 30 +------- lib/components/stats/top/tracks.dart | 42 ++--------- lib/pages/stats/minutes/minutes.dart | 44 +++++++++++ lib/pages/stats/streams/streams.dart | 44 +++++++++++ 11 files changed, 313 insertions(+), 136 deletions(-) create mode 100644 lib/components/stats/common/album_item.dart create mode 100644 lib/components/stats/common/artist_item.dart create mode 100644 lib/components/stats/common/track_item.dart diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index 2ce29b40..702aca9f 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -24,7 +24,9 @@ import 'package:spotube/pages/search/search.dart'; import 'package:spotube/pages/settings/blacklist.dart'; import 'package:spotube/pages/settings/about.dart'; import 'package:spotube/pages/settings/logs.dart'; +import 'package:spotube/pages/stats/minutes/minutes.dart'; import 'package:spotube/pages/stats/stats.dart'; +import 'package:spotube/pages/stats/streams/streams.dart'; import 'package:spotube/pages/track/track.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; @@ -230,6 +232,22 @@ final routerProvider = Provider((ref) { pageBuilder: (context, state) => const SpotubePage( child: StatsPage(), ), + routes: [ + GoRoute( + path: "minutes", + name: StatsMinutesPage.name, + pageBuilder: (context, state) => const SpotubePage( + child: StatsMinutesPage(), + ), + ), + GoRoute( + path: "streams", + name: StatsStreamsPage.name, + pageBuilder: (context, state) => const SpotubePage( + child: StatsStreamsPage(), + ), + ) + ], ) ], ), diff --git a/lib/components/stats/common/album_item.dart b/lib/components/stats/common/album_item.dart new file mode 100644 index 00000000..ccc0fa4e --- /dev/null +++ b/lib/components/stats/common/album_item.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/album/album_card.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/links/artist_link.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/pages/album/album.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class StatsAlbumItem extends StatelessWidget { + final AlbumSimple album; + final Widget info; + const StatsAlbumItem({super.key, required this.album, required this.info}); + + @override + Widget build(BuildContext context) { + return ListTile( + horizontalTitleGap: 8, + leading: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: UniversalImage( + path: (album.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + width: 40, + height: 40, + ), + ), + title: Text(album.name!), + subtitle: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text("${album.albumType?.formatted} • "), + Flexible( + child: ArtistLink( + artists: album.artists!, + mainAxisAlignment: WrapAlignment.start, + ), + ), + ], + ), + trailing: info, + onTap: () { + ServiceUtils.pushNamed( + context, + AlbumPage.name, + pathParameters: {"id": album.id!}, + extra: album, + ); + }, + ); + } +} diff --git a/lib/components/stats/common/artist_item.dart b/lib/components/stats/common/artist_item.dart new file mode 100644 index 00000000..9282d4e1 --- /dev/null +++ b/lib/components/stats/common/artist_item.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/pages/artist/artist.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class StatsArtistItem extends StatelessWidget { + final Artist artist; + final Widget info; + const StatsArtistItem({ + super.key, + required this.artist, + required this.info, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(artist.name!), + horizontalTitleGap: 8, + leading: CircleAvatar( + backgroundImage: UniversalImage.imageProvider( + (artist.images).asUrlString( + placeholder: ImagePlaceholder.artist, + ), + ), + ), + trailing: info, + onTap: () { + ServiceUtils.pushNamed( + context, + ArtistPage.name, + pathParameters: {"id": artist.id!}, + ); + }, + ); + } +} diff --git a/lib/components/stats/common/track_item.dart b/lib/components/stats/common/track_item.dart new file mode 100644 index 00000000..6ba6b886 --- /dev/null +++ b/lib/components/stats/common/track_item.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/links/artist_link.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/pages/track/track.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class StatsTrackItem extends StatelessWidget { + final Track track; + final Widget info; + const StatsTrackItem({ + super.key, + required this.track, + required this.info, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + horizontalTitleGap: 8, + leading: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: UniversalImage( + path: (track.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + width: 40, + height: 40, + ), + ), + title: Text(track.name!), + subtitle: ArtistLink( + artists: track.artists!, + mainAxisAlignment: WrapAlignment.start, + ), + trailing: info, + onTap: () { + ServiceUtils.pushNamed( + context, + TrackPage.name, + pathParameters: { + "id": track.id!, + }, + ); + }, + ); + } +} diff --git a/lib/components/stats/summary/summary.dart b/lib/components/stats/summary/summary.dart index ba4c7641..69524376 100644 --- a/lib/components/stats/summary/summary.dart +++ b/lib/components/stats/summary/summary.dart @@ -3,7 +3,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/stats/summary/summary_card.dart'; import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/pages/stats/minutes/minutes.dart'; +import 'package:spotube/pages/stats/streams/streams.dart'; import 'package:spotube/provider/history/summary.dart'; +import 'package:spotube/utils/service_utils.dart'; class StatsPageSummarySection extends HookConsumerWidget { const StatsPageSummarySection({super.key}); @@ -34,12 +37,18 @@ class StatsPageSummarySection extends HookConsumerWidget { unit: "minutes", description: 'Listened to music', color: Colors.purple, + onTap: () { + ServiceUtils.pushNamed(context, StatsMinutesPage.name); + }, ), SummaryCard( title: summary.tracks.toDouble(), unit: "songs", description: 'Streamed overall', color: Colors.lightBlue, + onTap: () { + ServiceUtils.pushNamed(context, StatsStreamsPage.name); + }, ), SummaryCard.unformatted( title: usdFormatter.format(summary.fees.toDouble()), diff --git a/lib/components/stats/summary/summary_card.dart b/lib/components/stats/summary/summary_card.dart index 7ab974a6..243c50e8 100644 --- a/lib/components/stats/summary/summary_card.dart +++ b/lib/components/stats/summary/summary_card.dart @@ -7,6 +7,7 @@ class SummaryCard extends StatelessWidget { final String title; final String unit; final String description; + final VoidCallback? onTap; final MaterialColor color; @@ -16,6 +17,7 @@ class SummaryCard extends StatelessWidget { required this.unit, required this.description, required this.color, + this.onTap, }) : title = compactNumberFormatter.format(title); const SummaryCard.unformatted({ @@ -24,6 +26,7 @@ class SummaryCard extends StatelessWidget { required this.unit, required this.description, required this.color, + this.onTap, }); @override @@ -34,44 +37,48 @@ class SummaryCard extends StatelessWidget { return Card( color: brightness == Brightness.dark ? color.shade100 : color.shade50, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 15), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AutoSizeText.rich( - TextSpan( - children: [ - TextSpan( - text: title, - style: textTheme.headlineLarge?.copyWith( - color: color.shade900, + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 15), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AutoSizeText.rich( + TextSpan( + children: [ + TextSpan( + text: title, + style: textTheme.headlineLarge?.copyWith( + color: color.shade900, + ), ), - ), - TextSpan( - text: " $unit", - style: textTheme.titleMedium?.copyWith( - color: color.shade900, + TextSpan( + text: " $unit", + style: textTheme.titleMedium?.copyWith( + color: color.shade900, + ), ), - ), - ], + ], + ), + maxLines: 1, ), - maxLines: 1, - ), - const Gap(5), - AutoSizeText( - description, - maxLines: description.contains("\n") - ? descriptionNewLines.length + 1 - : 1, - minFontSize: 9, - style: textTheme.labelMedium!.copyWith( - color: color.shade900, + const Gap(5), + AutoSizeText( + description, + maxLines: description.contains("\n") + ? descriptionNewLines.length + 1 + : 1, + minFontSize: 9, + style: textTheme.labelMedium!.copyWith( + color: color.shade900, + ), ), - ), - ], + ], + ), ), ), ); diff --git a/lib/components/stats/top/albums.dart b/lib/components/stats/top/albums.dart index 8d8d2b5c..51bcf5b0 100644 --- a/lib/components/stats/top/albums.dart +++ b/lib/components/stats/top/albums.dart @@ -1,13 +1,8 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/album/album_card.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/links/artist_link.dart'; -import 'package:spotube/extensions/image.dart'; -import 'package:spotube/pages/album/album.dart'; +import 'package:spotube/components/stats/common/album_item.dart'; import 'package:spotube/provider/history/top.dart'; -import 'package:spotube/utils/service_utils.dart'; class TopAlbums extends HookConsumerWidget { const TopAlbums({super.key}); @@ -22,44 +17,11 @@ class TopAlbums extends HookConsumerWidget { itemCount: albums.length, itemBuilder: (context, index) { final album = albums[index]; - return ListTile( - horizontalTitleGap: 8, - leading: ClipRRect( - borderRadius: BorderRadius.circular(4), - child: UniversalImage( - path: (album.album.images).asUrlString( - placeholder: ImagePlaceholder.albumArt, - ), - width: 40, - height: 40, - ), - ), - title: Text(album.album.name!), - subtitle: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text("${album.album.albumType?.formatted} • "), - Flexible( - child: ArtistLink( - artists: album.album.artists!, - mainAxisAlignment: WrapAlignment.start, - ), - ), - ], - ), - trailing: Text( + return StatsAlbumItem( + album: album.album, + info: Text( "${compactNumberFormatter.format(album.count)} plays", ), - onTap: () { - ServiceUtils.pushNamed( - context, - AlbumPage.name, - pathParameters: { - "id": album.album.id!, - }, - extra: album.album, - ); - }, ); }, ); diff --git a/lib/components/stats/top/artists.dart b/lib/components/stats/top/artists.dart index 05b5b082..d6d0c98d 100644 --- a/lib/components/stats/top/artists.dart +++ b/lib/components/stats/top/artists.dart @@ -1,11 +1,8 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/extensions/image.dart'; -import 'package:spotube/pages/artist/artist.dart'; +import 'package:spotube/components/stats/common/artist_item.dart'; import 'package:spotube/provider/history/top.dart'; -import 'package:spotube/utils/service_utils.dart'; class TopArtists extends HookConsumerWidget { const TopArtists({super.key}); @@ -20,28 +17,9 @@ class TopArtists extends HookConsumerWidget { itemCount: artists.length, itemBuilder: (context, index) { final artist = artists[index]; - return ListTile( - title: Text(artist.artist.name!), - horizontalTitleGap: 8, - leading: CircleAvatar( - backgroundImage: UniversalImage.imageProvider( - (artist.artist.images).asUrlString( - placeholder: ImagePlaceholder.artist, - ), - ), - ), - trailing: Text( - "${compactNumberFormatter.format(artist.count)} plays", - ), - onTap: () { - ServiceUtils.pushNamed( - context, - ArtistPage.name, - pathParameters: { - "id": artist.artist.id!, - }, - ); - }, + return StatsArtistItem( + artist: artist.artist, + info: Text("${compactNumberFormatter.format(artist.count)} plays"), ); }, ); diff --git a/lib/components/stats/top/tracks.dart b/lib/components/stats/top/tracks.dart index 26a5df16..bffa4ecd 100644 --- a/lib/components/stats/top/tracks.dart +++ b/lib/components/stats/top/tracks.dart @@ -1,12 +1,8 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/links/artist_link.dart'; -import 'package:spotube/extensions/image.dart'; -import 'package:spotube/pages/track/track.dart'; +import 'package:spotube/components/stats/common/track_item.dart'; import 'package:spotube/provider/history/top.dart'; -import 'package:spotube/utils/service_utils.dart'; class TopTracks extends HookConsumerWidget { const TopTracks({super.key}); @@ -14,42 +10,20 @@ class TopTracks extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final historyDuration = ref.watch(playbackHistoryTopDurationProvider); - final tracks = ref.watch(playbackHistoryTopProvider(historyDuration) - .select((value) => value.tracks)); + final tracks = ref.watch( + playbackHistoryTopProvider(historyDuration) + .select((value) => value.tracks), + ); return SliverList.builder( itemCount: tracks.length, itemBuilder: (context, index) { final track = tracks[index]; - return ListTile( - horizontalTitleGap: 8, - leading: ClipRRect( - borderRadius: BorderRadius.circular(4), - child: UniversalImage( - path: (track.track.album?.images).asUrlString( - placeholder: ImagePlaceholder.albumArt, - ), - width: 40, - height: 40, - ), - ), - title: Text(track.track.name!), - subtitle: ArtistLink( - artists: track.track.artists!, - mainAxisAlignment: WrapAlignment.start, - ), - trailing: Text( + return StatsTrackItem( + track: track.track, + info: Text( "${compactNumberFormatter.format(track.count)} plays", ), - onTap: () { - ServiceUtils.pushNamed( - context, - TrackPage.name, - pathParameters: { - "id": track.track.id!, - }, - ); - }, ); }, ); diff --git a/lib/pages/stats/minutes/minutes.dart b/lib/pages/stats/minutes/minutes.dart index e69de29b..b22f9a4f 100644 --- a/lib/pages/stats/minutes/minutes.dart +++ b/lib/pages/stats/minutes/minutes.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/formatters.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/stats/common/track_item.dart'; +import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/provider/history/top.dart'; + +class StatsMinutesPage extends HookConsumerWidget { + static const name = "stats_minutes"; + + const StatsMinutesPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final topTracks = ref.watch( + playbackHistoryTopProvider(HistoryDuration.allTime) + .select((s) => s.tracks), + ); + + return Scaffold( + appBar: const PageWindowTitleBar( + title: Text("Minutes listened"), + centerTitle: false, + automaticallyImplyLeading: true, + ), + body: ListView.separated( + separatorBuilder: (context, index) => const Gap(8), + itemCount: topTracks.length, + itemBuilder: (context, index) { + final (:track, :count) = topTracks[index]; + + return StatsTrackItem( + track: track, + info: Text( + "${compactNumberFormatter.format(count * track.duration!.inMinutes)} mins", + ), + ); + }, + ), + ); + } +} diff --git a/lib/pages/stats/streams/streams.dart b/lib/pages/stats/streams/streams.dart index e69de29b..33480709 100644 --- a/lib/pages/stats/streams/streams.dart +++ b/lib/pages/stats/streams/streams.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/formatters.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/stats/common/track_item.dart'; +import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/provider/history/top.dart'; + +class StatsStreamsPage extends HookConsumerWidget { + static const name = "stats_streams"; + + const StatsStreamsPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final topTracks = ref.watch( + playbackHistoryTopProvider(HistoryDuration.allTime) + .select((s) => s.tracks), + ); + + return Scaffold( + appBar: const PageWindowTitleBar( + title: Text("Streamed songs"), + centerTitle: false, + automaticallyImplyLeading: true, + ), + body: ListView.separated( + separatorBuilder: (context, index) => const Gap(8), + itemCount: topTracks.length, + itemBuilder: (context, index) { + final (:track, :count) = topTracks[index]; + + return StatsTrackItem( + track: track, + info: Text( + "${compactNumberFormatter.format(count)} streams", + ), + ); + }, + ), + ); + } +}