feat: add individual minutes and streams page

This commit is contained in:
Kingkor Roy Tirtho 2024-05-09 23:44:43 +06:00
parent 4d5beb19fe
commit f0b6d660e2
11 changed files with 313 additions and 136 deletions

View File

@ -24,7 +24,9 @@ import 'package:spotube/pages/search/search.dart';
import 'package:spotube/pages/settings/blacklist.dart'; import 'package:spotube/pages/settings/blacklist.dart';
import 'package:spotube/pages/settings/about.dart'; import 'package:spotube/pages/settings/about.dart';
import 'package:spotube/pages/settings/logs.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/stats.dart';
import 'package:spotube/pages/stats/streams/streams.dart';
import 'package:spotube/pages/track/track.dart'; import 'package:spotube/pages/track/track.dart';
import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/services/kv_store/kv_store.dart';
@ -230,6 +232,22 @@ final routerProvider = Provider((ref) {
pageBuilder: (context, state) => const SpotubePage( pageBuilder: (context, state) => const SpotubePage(
child: StatsPage(), 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(),
),
)
],
) )
], ],
), ),

View File

@ -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,
);
},
);
}
}

View File

@ -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!},
);
},
);
}
}

View File

@ -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!,
},
);
},
);
}
}

View File

@ -3,7 +3,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/formatters.dart'; import 'package:spotube/collections/formatters.dart';
import 'package:spotube/components/stats/summary/summary_card.dart'; import 'package:spotube/components/stats/summary/summary_card.dart';
import 'package:spotube/extensions/constrains.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/provider/history/summary.dart';
import 'package:spotube/utils/service_utils.dart';
class StatsPageSummarySection extends HookConsumerWidget { class StatsPageSummarySection extends HookConsumerWidget {
const StatsPageSummarySection({super.key}); const StatsPageSummarySection({super.key});
@ -34,12 +37,18 @@ class StatsPageSummarySection extends HookConsumerWidget {
unit: "minutes", unit: "minutes",
description: 'Listened to music', description: 'Listened to music',
color: Colors.purple, color: Colors.purple,
onTap: () {
ServiceUtils.pushNamed(context, StatsMinutesPage.name);
},
), ),
SummaryCard( SummaryCard(
title: summary.tracks.toDouble(), title: summary.tracks.toDouble(),
unit: "songs", unit: "songs",
description: 'Streamed overall', description: 'Streamed overall',
color: Colors.lightBlue, color: Colors.lightBlue,
onTap: () {
ServiceUtils.pushNamed(context, StatsStreamsPage.name);
},
), ),
SummaryCard.unformatted( SummaryCard.unformatted(
title: usdFormatter.format(summary.fees.toDouble()), title: usdFormatter.format(summary.fees.toDouble()),

View File

@ -7,6 +7,7 @@ class SummaryCard extends StatelessWidget {
final String title; final String title;
final String unit; final String unit;
final String description; final String description;
final VoidCallback? onTap;
final MaterialColor color; final MaterialColor color;
@ -16,6 +17,7 @@ class SummaryCard extends StatelessWidget {
required this.unit, required this.unit,
required this.description, required this.description,
required this.color, required this.color,
this.onTap,
}) : title = compactNumberFormatter.format(title); }) : title = compactNumberFormatter.format(title);
const SummaryCard.unformatted({ const SummaryCard.unformatted({
@ -24,6 +26,7 @@ class SummaryCard extends StatelessWidget {
required this.unit, required this.unit,
required this.description, required this.description,
required this.color, required this.color,
this.onTap,
}); });
@override @override
@ -34,44 +37,48 @@ class SummaryCard extends StatelessWidget {
return Card( return Card(
color: brightness == Brightness.dark ? color.shade100 : color.shade50, color: brightness == Brightness.dark ? color.shade100 : color.shade50,
child: Padding( child: InkWell(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 15), borderRadius: BorderRadius.circular(16),
child: Column( onTap: onTap,
mainAxisSize: MainAxisSize.min, child: Padding(
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 15),
mainAxisAlignment: MainAxisAlignment.center, child: Column(
children: [ mainAxisSize: MainAxisSize.min,
AutoSizeText.rich( crossAxisAlignment: CrossAxisAlignment.start,
TextSpan( mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
TextSpan( AutoSizeText.rich(
text: title, TextSpan(
style: textTheme.headlineLarge?.copyWith( children: [
color: color.shade900, TextSpan(
text: title,
style: textTheme.headlineLarge?.copyWith(
color: color.shade900,
),
), ),
), TextSpan(
TextSpan( text: " $unit",
text: " $unit", style: textTheme.titleMedium?.copyWith(
style: textTheme.titleMedium?.copyWith( color: color.shade900,
color: color.shade900, ),
), ),
), ],
], ),
maxLines: 1,
), ),
maxLines: 1, const Gap(5),
), AutoSizeText(
const Gap(5), description,
AutoSizeText( maxLines: description.contains("\n")
description, ? descriptionNewLines.length + 1
maxLines: description.contains("\n") : 1,
? descriptionNewLines.length + 1 minFontSize: 9,
: 1, style: textTheme.labelMedium!.copyWith(
minFontSize: 9, color: color.shade900,
style: textTheme.labelMedium!.copyWith( ),
color: color.shade900,
), ),
), ],
], ),
), ),
), ),
); );

View File

@ -1,13 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/formatters.dart'; import 'package:spotube/collections/formatters.dart';
import 'package:spotube/components/album/album_card.dart'; import 'package:spotube/components/stats/common/album_item.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/provider/history/top.dart'; import 'package:spotube/provider/history/top.dart';
import 'package:spotube/utils/service_utils.dart';
class TopAlbums extends HookConsumerWidget { class TopAlbums extends HookConsumerWidget {
const TopAlbums({super.key}); const TopAlbums({super.key});
@ -22,44 +17,11 @@ class TopAlbums extends HookConsumerWidget {
itemCount: albums.length, itemCount: albums.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final album = albums[index]; final album = albums[index];
return ListTile( return StatsAlbumItem(
horizontalTitleGap: 8, album: album.album,
leading: ClipRRect( info: Text(
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(
"${compactNumberFormatter.format(album.count)} plays", "${compactNumberFormatter.format(album.count)} plays",
), ),
onTap: () {
ServiceUtils.pushNamed(
context,
AlbumPage.name,
pathParameters: {
"id": album.album.id!,
},
extra: album.album,
);
},
); );
}, },
); );

View File

@ -1,11 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/formatters.dart'; import 'package:spotube/collections/formatters.dart';
import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/stats/common/artist_item.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/pages/artist/artist.dart';
import 'package:spotube/provider/history/top.dart'; import 'package:spotube/provider/history/top.dart';
import 'package:spotube/utils/service_utils.dart';
class TopArtists extends HookConsumerWidget { class TopArtists extends HookConsumerWidget {
const TopArtists({super.key}); const TopArtists({super.key});
@ -20,28 +17,9 @@ class TopArtists extends HookConsumerWidget {
itemCount: artists.length, itemCount: artists.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final artist = artists[index]; final artist = artists[index];
return ListTile( return StatsArtistItem(
title: Text(artist.artist.name!), artist: artist.artist,
horizontalTitleGap: 8, info: Text("${compactNumberFormatter.format(artist.count)} plays"),
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!,
},
);
},
); );
}, },
); );

View File

@ -1,12 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/formatters.dart'; import 'package:spotube/collections/formatters.dart';
import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/stats/common/track_item.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/provider/history/top.dart'; import 'package:spotube/provider/history/top.dart';
import 'package:spotube/utils/service_utils.dart';
class TopTracks extends HookConsumerWidget { class TopTracks extends HookConsumerWidget {
const TopTracks({super.key}); const TopTracks({super.key});
@ -14,42 +10,20 @@ class TopTracks extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final historyDuration = ref.watch(playbackHistoryTopDurationProvider); final historyDuration = ref.watch(playbackHistoryTopDurationProvider);
final tracks = ref.watch(playbackHistoryTopProvider(historyDuration) final tracks = ref.watch(
.select((value) => value.tracks)); playbackHistoryTopProvider(historyDuration)
.select((value) => value.tracks),
);
return SliverList.builder( return SliverList.builder(
itemCount: tracks.length, itemCount: tracks.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final track = tracks[index]; final track = tracks[index];
return ListTile( return StatsTrackItem(
horizontalTitleGap: 8, track: track.track,
leading: ClipRRect( info: Text(
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(
"${compactNumberFormatter.format(track.count)} plays", "${compactNumberFormatter.format(track.count)} plays",
), ),
onTap: () {
ServiceUtils.pushNamed(
context,
TrackPage.name,
pathParameters: {
"id": track.track.id!,
},
);
},
); );
}, },
); );

View File

@ -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",
),
);
},
),
);
}
}

View File

@ -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",
),
);
},
),
);
}
}