From 5ac71ba8c2e40a7a0aac0d651305f71e48f495f9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Beno=C3=AEt=20Harrault?= <benoit@harrault.fr>
Date: Fri, 27 Oct 2023 13:39:52 +0200
Subject: [PATCH] Add "Scrobbles per day" chart

---
 android/gradle.properties                     |   4 +-
 assets/translations/en.json                   |  10 +-
 assets/translations/fr.json                   |  10 +-
 .../metadata/android/en-US/changelogs/9.txt   |   1 +
 .../metadata/android/fr-FR/changelogs/9.txt   |   1 +
 lib/models/counts_by_day.dart                 |  36 +++
 lib/network/scrobbles_api.dart                |  12 +
 lib/ui/screens/main_screen.dart               |   3 +
 .../main_screen/counts_by_day_card.dart       |  48 ++++
 .../main_screen/counts_by_day_chart.dart      | 238 ++++++++++++++++++
 .../main_screen/counts_by_day_content.dart    |  43 ++++
 pubspec.yaml                                  |   2 +-
 12 files changed, 403 insertions(+), 5 deletions(-)
 create mode 100644 fastlane/metadata/android/en-US/changelogs/9.txt
 create mode 100644 fastlane/metadata/android/fr-FR/changelogs/9.txt
 create mode 100644 lib/models/counts_by_day.dart
 create mode 100644 lib/ui/widgets/main_screen/counts_by_day_card.dart
 create mode 100644 lib/ui/widgets/main_screen/counts_by_day_chart.dart
 create mode 100644 lib/ui/widgets/main_screen/counts_by_day_content.dart

diff --git a/android/gradle.properties b/android/gradle.properties
index 65eed64..4bb5439 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.8
-app.versionCode=8
+app.versionName=0.0.9
+app.versionCode=9
diff --git a/assets/translations/en.json b/assets/translations/en.json
index 179e72f..8c5b323 100644
--- a/assets/translations/en.json
+++ b/assets/translations/en.json
@@ -8,5 +8,13 @@
   "statistics_recent_scrobbles_count": "Scrobbles: {count}",
   "statistics_discoveries": "Discoveries: {artistsCount} artists / {tracksCount} tracks",
 
-  "timeline_title": "Recent scrobbles ({daysCount} days)"
+  "timeline_title": "Recent scrobbles ({daysCount} days)",
+  "counts_by_day": "Counts by day ({daysCount} days)",
+  "MON": "MON",
+  "TUE": "TUE",
+  "WED": "WED",
+  "THU": "THU",
+  "FRI": "FRI",
+  "SAT": "SAT",
+  "SUN": "SUN"
 }
diff --git a/assets/translations/fr.json b/assets/translations/fr.json
index 064f98c..a577ce7 100644
--- a/assets/translations/fr.json
+++ b/assets/translations/fr.json
@@ -8,5 +8,13 @@
   "statistics_recent_scrobbles_count": "Écoutes : {count}",
   "statistics_discoveries": "Découvertes : {artistsCount} artistes / {tracksCount} morceaux",
 
-  "timeline_title": "Écoutes récentes ({daysCount} jours)"
+  "timeline_title": "Écoutes récentes ({daysCount} jours)",
+  "counts_by_day": "Écoutes par jour ({daysCount} jours)",
+  "MON": "LUN",
+  "TUE": "MAR",
+  "WED": "MER",
+  "THU": "JEU",
+  "FRI": "VEN",
+  "SAT": "SAM",
+  "SUN": "DIM"
 }
diff --git a/fastlane/metadata/android/en-US/changelogs/9.txt b/fastlane/metadata/android/en-US/changelogs/9.txt
new file mode 100644
index 0000000..6240437
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/9.txt
@@ -0,0 +1 @@
+Add chart "scrobbles counts per day of week".
diff --git a/fastlane/metadata/android/fr-FR/changelogs/9.txt b/fastlane/metadata/android/fr-FR/changelogs/9.txt
new file mode 100644
index 0000000..7b445c2
--- /dev/null
+++ b/fastlane/metadata/android/fr-FR/changelogs/9.txt
@@ -0,0 +1 @@
+Ajout du graphique du nombre d'écoutes par jour de la semaine.
diff --git a/lib/models/counts_by_day.dart b/lib/models/counts_by_day.dart
new file mode 100644
index 0000000..cff1d53
--- /dev/null
+++ b/lib/models/counts_by_day.dart
@@ -0,0 +1,36 @@
+import 'dart:convert';
+
+class CountsByDayData {
+  final Map<int, double> data;
+
+  const CountsByDayData({
+    required this.data,
+  });
+
+  factory CountsByDayData.fromJson(Map<String, dynamic> json) {
+    Map<int, double> data = {};
+
+    if (json['counts-by-day'] != null) {
+      json['counts-by-day'].keys.forEach((day) {
+        data[int.parse(day)] = double.parse(json['counts-by-day'][day].toString());
+      });
+    }
+
+    return CountsByDayData(data: data);
+  }
+
+  factory CountsByDayData.createEmpty() {
+    return CountsByDayData.fromJson({});
+  }
+
+  String toString() {
+    Map<String, double> map = {};
+
+    this.data.keys.forEach((day) {
+      double? value = this.data[day];
+      map[day.toString()] = value != null ? value.toDouble() : 0.0;
+    });
+
+    return jsonEncode({'counts-by-day': map});
+  }
+}
diff --git a/lib/network/scrobbles_api.dart b/lib/network/scrobbles_api.dart
index 64017d0..b30cb39 100644
--- a/lib/network/scrobbles_api.dart
+++ b/lib/network/scrobbles_api.dart
@@ -1,6 +1,7 @@
 import 'dart:convert';
 import 'package:http/http.dart' as http;
 
+import '../models/counts_by_day.dart';
 import '../models/statistics.dart';
 import '../models/timeline.dart';
 
@@ -27,4 +28,15 @@ class ScrobblesApi {
       throw Exception('Failed to get data from API.');
     }
   }
+
+  static Future<CountsByDayData> fetchCountsByDay(int daysCount) async {
+    final String url = baseUrl + '/data/' + daysCount.toString() + '/counts-by-day';
+    final response = await http.get(Uri.parse(url));
+
+    if (response.statusCode == 200) {
+      return CountsByDayData.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
+    } else {
+      throw Exception('Failed to get data from API.');
+    }
+  }
 }
diff --git a/lib/ui/screens/main_screen.dart b/lib/ui/screens/main_screen.dart
index 17f0fb5..1227308 100644
--- a/lib/ui/screens/main_screen.dart
+++ b/lib/ui/screens/main_screen.dart
@@ -1,6 +1,7 @@
 import 'package:flutter/material.dart';
 
 import '../widgets/header.dart';
+import '../widgets/main_screen/counts_by_day_card.dart';
 import '../widgets/main_screen/statistics_card.dart';
 import '../widgets/main_screen/timeline_card.dart';
 
@@ -19,6 +20,8 @@ class MainScreen extends StatelessWidget {
           const StatisticsCard(),
           const SizedBox(height: 8),
           const ChartTimelineCard(),
+          const SizedBox(height: 8),
+          const ChartCountsByDayCard(),
           const SizedBox(height: 36),
         ],
       ),
diff --git a/lib/ui/widgets/main_screen/counts_by_day_card.dart b/lib/ui/widgets/main_screen/counts_by_day_card.dart
new file mode 100644
index 0000000..6cd39c1
--- /dev/null
+++ b/lib/ui/widgets/main_screen/counts_by_day_card.dart
@@ -0,0 +1,48 @@
+import 'dart:convert';
+
+import 'package:flutter/material.dart';
+
+import '../../../models/counts_by_day.dart';
+import '../../../network/scrobbles_api.dart';
+import '../../../ui/widgets/error.dart';
+import '../../../ui/widgets/main_screen/counts_by_day_content.dart';
+
+class ChartCountsByDayCard extends StatelessWidget {
+  const ChartCountsByDayCard({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    final int daysCount = 21;
+    late Future<CountsByDayData> futureCountsByDay = ScrobblesApi.fetchCountsByDay(daysCount);
+
+    return FutureBuilder<CountsByDayData>(
+      future: futureCountsByDay,
+      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.primary,
+          shape: const RoundedRectangleBorder(
+            borderRadius: BorderRadius.all(
+              Radius.circular(12),
+            ),
+          ),
+          child: Padding(
+            padding: const EdgeInsets.all(12.0),
+            child: ChartCountsByDayCardContent(
+              daysCount: daysCount,
+              chartData: snapshot.hasData
+                  ? CountsByDayData.fromJson(jsonDecode(snapshot.data.toString()))
+                  : CountsByDayData.createEmpty(),
+              isLoading: !snapshot.hasData,
+            ),
+          ),
+        );
+      },
+    );
+  }
+}
diff --git a/lib/ui/widgets/main_screen/counts_by_day_chart.dart b/lib/ui/widgets/main_screen/counts_by_day_chart.dart
new file mode 100644
index 0000000..10e01e8
--- /dev/null
+++ b/lib/ui/widgets/main_screen/counts_by_day_chart.dart
@@ -0,0 +1,238 @@
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:fl_chart/fl_chart.dart';
+
+import '../../../config/app_colors.dart';
+import '../../../models/counts_by_day.dart';
+import '../../../utils/color_extensions.dart';
+
+class CountsByDayCardContentChart extends StatelessWidget {
+  final CountsByDayData chartData;
+
+  const CountsByDayCardContentChart({super.key, required this.chartData});
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      height: 100.0,
+      child: LayoutBuilder(builder: (context, constraints) {
+        final double maxWidth = constraints.maxWidth;
+        final int barsCount = this.chartData.data.keys.length;
+        final double barWidth = 0.7 * (maxWidth / barsCount);
+
+        return BarChart(
+          BarChartData(
+            barGroups: getDataCounts(barWidth),
+            backgroundColor: Theme.of(context).colorScheme.onBackground,
+            borderData: getBorderData(),
+            gridData: getGridData(),
+            titlesData: getTitlesData(),
+            barTouchData: getBarTouchData(),
+            maxY: getNextRoundNumber(getMaxCountsValue(), 10),
+          ),
+        );
+      }),
+    );
+  }
+
+  double getMaxCountsValue() {
+    double maxValue = 0;
+
+    this.chartData.data.keys.forEach((key) {
+      double? counts = this.chartData.data[key];
+      if (counts != null) {
+        if (counts > maxValue) {
+          maxValue = counts;
+        }
+      }
+    });
+
+    return maxValue;
+  }
+
+  double getNextRoundNumber(double number, int scale) {
+    return scale * ((number ~/ scale).toInt() + 1);
+  }
+
+  List<BarChartGroupData> getDataCounts(double barWidth) {
+    List<BarChartGroupData> data = [];
+
+    this.chartData.data.keys.forEach((day) {
+      final double? counts = this.chartData.data[day];
+
+      if (counts != null) {
+        Color barColor = AppColors.contentColorBlack;
+        switch (day) {
+          case 1:
+            barColor = Color.fromARGB(255, 255, 99, 132);
+            break;
+          case 2:
+            barColor = Color.fromARGB(255, 255, 159, 64);
+            break;
+          case 3:
+            barColor = Color.fromARGB(255, 255, 205, 86);
+            break;
+          case 4:
+            barColor = Color.fromARGB(255, 75, 192, 192);
+            break;
+          case 5:
+            barColor = Color.fromARGB(255, 54, 162, 235);
+            break;
+          case 6:
+            barColor = Color.fromARGB(255, 153, 102, 255);
+            break;
+          case 7:
+            barColor = Color.fromARGB(255, 201, 203, 207);
+            break;
+          default:
+        }
+
+        data.add(BarChartGroupData(
+          x: day,
+          barRods: [
+            BarChartRodData(
+              toY: counts,
+              color: barColor,
+              width: barWidth,
+              borderRadius: BorderRadius.all(Radius.zero),
+              borderSide: BorderSide(
+                color: barColor.darken(20),
+              ),
+            ),
+          ],
+        ));
+      }
+    });
+
+    return data;
+  }
+
+  FlBorderData getBorderData() {
+    return FlBorderData(
+      show: true,
+      border: Border.all(
+        color: AppColors.borderColor,
+        width: 2,
+      ),
+    );
+  }
+
+  FlGridData getGridData() {
+    return const FlGridData(
+      show: true,
+    );
+  }
+
+  FlTitlesData getTitlesData() {
+    const AxisTitles none = const AxisTitles(
+      sideTitles: SideTitles(showTitles: false),
+    );
+
+    final AxisTitles verticalTitles = AxisTitles(
+      sideTitles: SideTitles(
+        showTitles: true,
+        reservedSize: 30,
+        getTitlesWidget: getVerticalTitlesWidget,
+        interval: 10,
+      ),
+    );
+    final AxisTitles horizontalTitles = AxisTitles(
+      sideTitles: SideTitles(
+        showTitles: true,
+        reservedSize: 20,
+        getTitlesWidget: getHorizontalTitlesWidget,
+      ),
+    );
+
+    return FlTitlesData(
+      show: true,
+      bottomTitles: horizontalTitles,
+      leftTitles: none,
+      topTitles: none,
+      rightTitles: verticalTitles,
+    );
+  }
+
+  Widget getVerticalTitlesWidget(double value, TitleMeta meta) {
+    return SideTitleWidget(
+      axisSide: meta.axisSide,
+      space: 4,
+      child: Text(
+        value.toInt().toString(),
+        style: const TextStyle(
+          color: AppColors.mainTextColor1,
+          fontSize: 12,
+        ),
+      ),
+    );
+  }
+
+  Widget getHorizontalTitlesWidget(double value, TitleMeta meta) {
+    final int day = value.toInt();
+    String dayShortName = '';
+    switch (day) {
+      case 1:
+        dayShortName = 'MON';
+        break;
+      case 2:
+        dayShortName = 'TUE';
+        break;
+      case 3:
+        dayShortName = 'WED';
+        break;
+      case 4:
+        dayShortName = 'THU';
+        break;
+      case 5:
+        dayShortName = 'FRI';
+        break;
+      case 6:
+        dayShortName = 'SAT';
+        break;
+      case 7:
+        dayShortName = 'SUN';
+        break;
+      default:
+    }
+
+    final String text = tr(dayShortName);
+
+    return SideTitleWidget(
+      axisSide: meta.axisSide,
+      space: 2,
+      child: Text(
+        text,
+        style: const TextStyle(
+          color: AppColors.mainTextColor1,
+          fontSize: 11,
+        ),
+      ),
+    );
+  }
+
+  BarTouchData getBarTouchData() {
+    return BarTouchData(
+      enabled: true,
+      touchTooltipData: BarTouchTooltipData(
+        tooltipBgColor: Colors.transparent,
+        tooltipPadding: EdgeInsets.zero,
+        tooltipMargin: 2,
+        getTooltipItem: (
+          BarChartGroupData group,
+          int groupIndex,
+          BarChartRodData rod,
+          int rodIndex,
+        ) {
+          return BarTooltipItem(
+            rod.toY.round().toString(),
+            const TextStyle(
+              color: AppColors.mainTextColor2,
+              fontWeight: FontWeight.bold,
+              fontSize: 10,
+            ),
+          );
+        },
+      ),
+    );
+  }
+}
diff --git a/lib/ui/widgets/main_screen/counts_by_day_content.dart b/lib/ui/widgets/main_screen/counts_by_day_content.dart
new file mode 100644
index 0000000..3e31962
--- /dev/null
+++ b/lib/ui/widgets/main_screen/counts_by_day_content.dart
@@ -0,0 +1,43 @@
+import 'dart:convert';
+
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+
+import '../../../models/counts_by_day.dart';
+import '../../../ui/widgets/main_screen/counts_by_day_chart.dart';
+
+class ChartCountsByDayCardContent extends StatelessWidget {
+  final int daysCount;
+  final CountsByDayData chartData;
+  final bool isLoading;
+
+  const ChartCountsByDayCardContent(
+      {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(
+          'counts_by_day',
+          style: textTheme.titleLarge!.apply(fontWeightDelta: 2),
+        ).tr(
+          namedArgs: {
+            'daysCount': this.daysCount.toString(),
+          },
+        ),
+        const SizedBox(height: 8),
+        this.isLoading
+            ? Text(placeholder)
+            : CountsByDayCardContentChart(
+                chartData: CountsByDayData.fromJson(jsonDecode(this.chartData.toString())),
+              ),
+      ],
+    );
+  }
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index 6c0ad2f..c2888d4 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -3,7 +3,7 @@ description: Display scrobbles data and charts
 
 publish_to: 'none'
 
-version: 0.0.8+8
+version: 0.0.9+9
 
 environment:
   sdk: '^3.0.0'
-- 
GitLab