diff --git a/android/gradle.properties b/android/gradle.properties index c2a871af8ab063d06f9d9304e63850ba93031951..357cef39a7f1619a4f0ba1c191a85a0dd10b7266 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,5 +1,5 @@ org.gradle.jvmargs=-Xmx1536M android.useAndroidX=true android.enableJetifier=true -app.versionName=0.0.24 -app.versionCode=24 +app.versionName=0.0.25 +app.versionCode=25 diff --git a/assets/translations/en.json b/assets/translations/en.json index 8d3e0ce6b708b95cd3ea941ee36f8da578930254..449c95d379af3bf15d6faa1cd504e748f3df24c0 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -19,6 +19,8 @@ "discoveries_title": "Discoveries ({daysCount} days)", + "top_artists_title": "Top artists ({daysCount} days)", + "MON": "MON", "TUE": "TUE", "WED": "WED", diff --git a/assets/translations/fr.json b/assets/translations/fr.json index b7788bbfffb3ef91cef697a158ee71f33ad4d451..1b19c8a7d1b7905c8d72531914b57e00d49604bc 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -19,6 +19,8 @@ "discoveries_title": "Découvertes ({daysCount} jours)", + "top_artists_title": "Top artistes ({daysCount} jours)", + "MON": "LUN", "TUE": "MAR", "WED": "MER", diff --git a/fastlane/metadata/android/en-US/changelogs/25.txt b/fastlane/metadata/android/en-US/changelogs/25.txt new file mode 100644 index 0000000000000000000000000000000000000000..2e0f045ba5522c57246f6b810a524f97a3625614 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/25.txt @@ -0,0 +1 @@ +Add "top artists" chart. diff --git a/fastlane/metadata/android/fr-FR/changelogs/25.txt b/fastlane/metadata/android/fr-FR/changelogs/25.txt new file mode 100644 index 0000000000000000000000000000000000000000..5db8b2dd8ab9ab93ce53451cd21be22683a31a59 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/25.txt @@ -0,0 +1 @@ +Ajout du graphique "top artistes". diff --git a/lib/models/topartists.dart b/lib/models/topartists.dart new file mode 100644 index 0000000000000000000000000000000000000000..52c06f2373298b9a57a88f9d1e295f751db3ae0e --- /dev/null +++ b/lib/models/topartists.dart @@ -0,0 +1,61 @@ +import 'dart:convert'; + +class TopArtistsDataValue { + final String artistName; + final int count; + + const TopArtistsDataValue({required this.artistName, required this.count}); + + factory TopArtistsDataValue.fromJson(Map<String, dynamic> json) { + return TopArtistsDataValue( + artistName: json['artistName'] as String, + count: json['count'] as int, + ); + } +} + +class TopArtistsData { + final List<TopArtistsDataValue> topArtists; + + const TopArtistsData({ + required this.topArtists, + }); + + factory TopArtistsData.fromJson(Map<String, dynamic> json) { + List<TopArtistsDataValue> topArtists = []; + + json['top-artists'].forEach((element) { + TopArtistsDataValue value = TopArtistsDataValue( + artistName: element['artistName'] as String, + count: element['count'] as int, + ); + + topArtists.add(value); + }); + + return TopArtistsData( + topArtists: topArtists, + ); + } + + factory TopArtistsData.createEmpty() { + return TopArtistsData.fromJson({ + 'top-artists': [], + }); + } + + String toString() { + List<Map<String, Object>> listArtists = []; + + this.topArtists.forEach((TopArtistsDataValue? item) { + listArtists.add({ + 'artistName': item != null ? item.artistName : '', + 'count': item != null ? item.count : 0, + }); + }); + + return jsonEncode({ + 'top-artists': listArtists, + }); + } +} diff --git a/lib/network/scrobbles_api.dart b/lib/network/scrobbles_api.dart index 1638595e8df6c1f6e0a9c212b800c1811ffabf51..3efb3f6a8e22fa2b3f6570a6675016f2c61e495d 100644 --- a/lib/network/scrobbles_api.dart +++ b/lib/network/scrobbles_api.dart @@ -7,6 +7,7 @@ import '../models/discoveries.dart'; import '../models/statistics_global.dart'; import '../models/statistics_recent.dart'; import '../models/timeline.dart'; +import '../models/topartists.dart'; class ScrobblesApi { static String baseUrl = 'https://scrobble.harrault.fr'; @@ -82,4 +83,16 @@ class ScrobblesApi { throw Exception('Failed to get data from API.'); } } + + static Future<TopArtistsData> fetchTopArtists(int daysCount) async { + final String url = baseUrl + '/data/' + daysCount.toString() + '/top-artists'; + print('fetching ' + url); + final response = await http.get(Uri.parse(url)); + + if (response.statusCode == 200) { + return TopArtistsData.fromJson(jsonDecode(response.body) as Map<String, dynamic>); + } else { + throw Exception('Failed to get data from API.'); + } + } } diff --git a/lib/ui/screens/home_screen.dart b/lib/ui/screens/home_screen.dart index d05a634ffc38916cf4db521aa3b4af21861f3149..5af4a2d9022c41fbdcf7227dc376eae935bf7ffe 100644 --- a/lib/ui/screens/home_screen.dart +++ b/lib/ui/screens/home_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../widgets/main_screen/statistics_global_card.dart'; import '../widgets/main_screen/statistics_recent_card.dart'; import '../widgets/main_screen/timeline_card.dart'; +import '../widgets/main_screen/topartists_card.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -26,6 +27,8 @@ class _HomeScreenState extends State<HomeScreen> { const StatisticsRecentCard(), const SizedBox(height: 6), const ChartTimelineCard(), + const SizedBox(height: 6), + const ChartTopArtistsCard(), const SizedBox(height: 36), ], ), diff --git a/lib/ui/widgets/main_screen/topartists_card.dart b/lib/ui/widgets/main_screen/topartists_card.dart new file mode 100644 index 0000000000000000000000000000000000000000..e26de073de48ec1862749d77da6d776bd8518506 --- /dev/null +++ b/lib/ui/widgets/main_screen/topartists_card.dart @@ -0,0 +1,48 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +import '../../../models/topartists.dart'; +import '../../../network/scrobbles_api.dart'; +import '../../../ui/widgets/error.dart'; +import '../../../ui/widgets/main_screen/topartists_content.dart'; + +class ChartTopArtistsCard extends StatelessWidget { + const ChartTopArtistsCard({super.key}); + + @override + Widget build(BuildContext context) { + final int daysCount = 14; + late Future<TopArtistsData> futureTimeline = ScrobblesApi.fetchTopArtists(daysCount); + + return FutureBuilder<TopArtistsData>( + future: futureTimeline, + builder: (context, snapshot) { + if (snapshot.hasError) { + return ShowErrorWidget(message: '${snapshot.error}'); + } + + return Card( + elevation: 2, + shadowColor: Theme.of(context).colorScheme.shadow, + color: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(8), + ), + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: ChartTopArtistsCardContent( + daysCount: daysCount, + chartData: snapshot.hasData + ? TopArtistsData.fromJson(jsonDecode(snapshot.data.toString())) + : TopArtistsData.createEmpty(), + isLoading: !snapshot.hasData, + ), + ), + ); + }, + ); + } +} diff --git a/lib/ui/widgets/main_screen/topartists_chart.dart b/lib/ui/widgets/main_screen/topartists_chart.dart new file mode 100644 index 0000000000000000000000000000000000000000..776508d2f16f1e5364e61bf70a34035561ce318f --- /dev/null +++ b/lib/ui/widgets/main_screen/topartists_chart.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; + +import '../../../config/app_colors.dart'; +import '../../../models/topartists.dart'; +import '../../../utils/color_extensions.dart'; + +class ChartTopArtists extends StatelessWidget { + final TopArtistsData chartData; + + ChartTopArtists({super.key, required this.chartData}); + + @override + Widget build(BuildContext context) { + return AspectRatio( + aspectRatio: 2.1, + child: Row( + children: <Widget>[ + Expanded( + child: AspectRatio( + aspectRatio: 1, + child: PieChart( + PieChartData( + sections: getPieChartData(), + sectionsSpace: 1, + centerSpaceRadius: 40, + startDegreeOffset: -45, + pieTouchData: PieTouchData(enabled: false), + borderData: FlBorderData(show: false), + ), + ), + ), + ), + buildLegendWidget(), + ], + ), + ); + } + + Color getColorIndex(int index) { + const List<int> hexValues = [ + 0x8dd3c7, + 0xffffb3, + 0xbebada, + 0xfb8072, + 0x80b1d3, + 0xfdb462, + 0xb3de69, + 0xfccde5, + 0xd9d9d9, + 0xbc80bd, + 0xccebc5, + 0xffed6f, + ]; + + return Color(hexValues[index] + 0xff000000); + } + + List<PieChartSectionData> getPieChartData() { + List<PieChartSectionData> items = []; + + final radius = 40.0; + final fontSize = 11.0; + const shadows = [Shadow(color: Colors.black, blurRadius: 2)]; + + int index = 0; + + this.chartData.topArtists.forEach((element) { + items.add(PieChartSectionData( + value: element.count.toDouble(), + title: element.artistName, + color: getColorIndex(index++).darken(20), + radius: radius, + titleStyle: TextStyle( + fontSize: fontSize, + color: AppColors.mainTextColor1, + shadows: shadows, + ), + )); + }); + + return items; + } + + Widget buildLegendWidget() { + const double itemSize = 12; + + List<Widget> items = []; + int index = 0; + + this.chartData.topArtists.forEach((element) { + items.add(Row( + children: <Widget>[ + Container( + width: itemSize, + height: itemSize, + decoration: BoxDecoration( + shape: BoxShape.rectangle, + color: getColorIndex(index++).darken(20), + ), + ), + const SizedBox( + width: 4, + ), + Text( + element.artistName + ' (' + element.count.toString() + ')', + style: TextStyle( + fontSize: itemSize - 2, + fontWeight: FontWeight.bold, + ), + ) + ], + )); + }); + + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: items, + ); + } +} diff --git a/lib/ui/widgets/main_screen/topartists_content.dart b/lib/ui/widgets/main_screen/topartists_content.dart new file mode 100644 index 0000000000000000000000000000000000000000..00d41e804a75c0410a43cc18e739a55fed707b38 --- /dev/null +++ b/lib/ui/widgets/main_screen/topartists_content.dart @@ -0,0 +1,43 @@ +import 'dart:convert'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import '../../../models/topartists.dart'; +import '../../../ui/widgets/main_screen/topartists_chart.dart'; + +class ChartTopArtistsCardContent extends StatelessWidget { + final int daysCount; + final TopArtistsData chartData; + final bool isLoading; + + const ChartTopArtistsCardContent( + {super.key, required this.daysCount, required this.chartData, required this.isLoading}); + + @override + Widget build(BuildContext context) { + final TextTheme textTheme = Theme.of(context).primaryTextTheme; + final String placeholder = 'â³'; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: <Widget>[ + Text( + 'top_artists_title', + style: textTheme.titleLarge!.apply(fontWeightDelta: 2), + ).tr( + namedArgs: { + 'daysCount': this.daysCount.toString(), + }, + ), + const SizedBox(height: 8), + this.isLoading + ? Text(placeholder) + : ChartTopArtists( + chartData: TopArtistsData.fromJson(jsonDecode(this.chartData.toString())), + ), + ], + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index f60d4efd99db90ac3ff56be417eee80ad79c6a63..61d36396f230846a63ad5b5f8591955f19db32cd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Display scrobbles data and charts publish_to: 'none' -version: 0.0.24+24 +version: 0.0.25+25 environment: sdk: '^3.0.0'