From 38474042ec6417360b1c32c789752e5adeccb54f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Beno=C3=AEt=20Harrault?= <benoit@harrault.fr>
Date: Fri, 10 Nov 2023 13:53:50 +0100
Subject: [PATCH] Add "top artists" chart

---
 android/gradle.properties                     |   4 +-
 assets/translations/en.json                   |   2 +
 assets/translations/fr.json                   |   2 +
 .../metadata/android/en-US/changelogs/25.txt  |   1 +
 .../metadata/android/fr-FR/changelogs/25.txt  |   1 +
 lib/models/topartists.dart                    |  61 +++++++++
 lib/network/scrobbles_api.dart                |  13 ++
 lib/ui/screens/home_screen.dart               |   3 +
 .../widgets/main_screen/topartists_card.dart  |  48 +++++++
 .../widgets/main_screen/topartists_chart.dart | 122 ++++++++++++++++++
 .../main_screen/topartists_content.dart       |  43 ++++++
 pubspec.yaml                                  |   2 +-
 12 files changed, 299 insertions(+), 3 deletions(-)
 create mode 100644 fastlane/metadata/android/en-US/changelogs/25.txt
 create mode 100644 fastlane/metadata/android/fr-FR/changelogs/25.txt
 create mode 100644 lib/models/topartists.dart
 create mode 100644 lib/ui/widgets/main_screen/topartists_card.dart
 create mode 100644 lib/ui/widgets/main_screen/topartists_chart.dart
 create mode 100644 lib/ui/widgets/main_screen/topartists_content.dart

diff --git a/android/gradle.properties b/android/gradle.properties
index c2a871a..357cef3 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 8d3e0ce..449c95d 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 b7788bb..1b19c8a 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 0000000..2e0f045
--- /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 0000000..5db8b2d
--- /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 0000000..52c06f2
--- /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 1638595..3efb3f6 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 d05a634..5af4a2d 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 0000000..e26de07
--- /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 0000000..776508d
--- /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 0000000..00d41e8
--- /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 f60d4ef..61d3639 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'
-- 
GitLab