From 7d9aca6a20544be32f837bc5191ed73eee5c8533 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Harrault?= <benoit@harrault.fr> Date: Thu, 30 Nov 2023 17:16:18 +0100 Subject: [PATCH] Add top artists stream line --- android/gradle.properties | 4 +- .../metadata/android/en-US/changelogs/38.txt | 1 + .../metadata/android/fr-FR/changelogs/38.txt | 1 + lib/models/topartists.dart | 69 ++++++ .../widgets/abstracts/custom_bar_chart.dart | 2 +- .../widgets/abstracts/custom_line_chart.dart | 2 +- lib/ui/widgets/cards/top_artists.dart | 17 +- lib/ui/widgets/charts/top_artists_stream.dart | 224 ++++++++++++++++++ lib/ui/widgets/error.dart | 2 + pubspec.yaml | 2 +- 10 files changed, 317 insertions(+), 7 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/38.txt create mode 100644 fastlane/metadata/android/fr-FR/changelogs/38.txt create mode 100644 lib/ui/widgets/charts/top_artists_stream.dart diff --git a/android/gradle.properties b/android/gradle.properties index 9b5bace..604fc08 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.37 -app.versionCode=37 +app.versionName=0.0.38 +app.versionCode=38 diff --git a/fastlane/metadata/android/en-US/changelogs/38.txt b/fastlane/metadata/android/en-US/changelogs/38.txt new file mode 100644 index 0000000..17fb807 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/38.txt @@ -0,0 +1 @@ +Add top artists streamline chart. diff --git a/fastlane/metadata/android/fr-FR/changelogs/38.txt b/fastlane/metadata/android/fr-FR/changelogs/38.txt new file mode 100644 index 0000000..6197059 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/38.txt @@ -0,0 +1 @@ +Ajout graphique d'évolution du "top artistes". diff --git a/lib/models/topartists.dart b/lib/models/topartists.dart index 6c27190..a53f60e 100644 --- a/lib/models/topartists.dart +++ b/lib/models/topartists.dart @@ -14,15 +14,46 @@ class TopArtistsDataValue { } } +class TopArtistsStreamDataValue { + final String artistName; + final double value; + + const TopArtistsStreamDataValue({ + required this.artistName, + required this.value, + }); + + factory TopArtistsStreamDataValue.fromJson(Map<String, dynamic>? json) { + return TopArtistsStreamDataValue( + artistName: json?['artistName'] as String, + value: json?['value'] as double, + ); + } + + Map<String, dynamic>? toJson() { + return { + artistName: value, + }; + } + + @override + String toString() { + return jsonEncode(this.toJson()); + } +} + class TopArtistsData { final List<TopArtistsDataValue> topArtists; + final Map<String, List<TopArtistsStreamDataValue>> topArtistsStream; const TopArtistsData({ required this.topArtists, + required this.topArtistsStream, }); factory TopArtistsData.fromJson(Map<String, dynamic>? json) { List<TopArtistsDataValue> topArtists = []; + Map<String, List<TopArtistsStreamDataValue>> topArtistsStream = {}; json?['top-artists'].forEach((element) { TopArtistsDataValue value = TopArtistsDataValue( @@ -33,13 +64,39 @@ class TopArtistsData { topArtists.add(value); }); + json?['top-artists-stream-by-date'].keys.forEach((date) { + if (json['top-artists-stream-by-date'][date] is Map<String, dynamic>) { + Map<String, dynamic> content = json['top-artists-stream-by-date'][date]; + + List<TopArtistsStreamDataValue> items = []; + content.forEach((String artistName, dynamic rawValue) { + double value = 0.0; + if (rawValue is double) { + value = rawValue; + } else if (rawValue is int) { + value = rawValue.toDouble(); + } + TopArtistsStreamDataValue item = TopArtistsStreamDataValue( + artistName: artistName, + value: value, + ); + + items.add(item); + }); + + topArtistsStream[date] = items; + } + }); + return TopArtistsData( topArtists: topArtists, + topArtistsStream: topArtistsStream, ); } Map<String, Object?>? toJson() { List<Map<String, Object>> listArtists = []; + Map<String, List<Map<String, double>>> artistsStreamMap = {}; this.topArtists.forEach((TopArtistsDataValue? item) { listArtists.add({ @@ -48,8 +105,20 @@ class TopArtistsData { }); }); + this.topArtistsStream.keys.forEach((dateAsString) { + List<TopArtistsStreamDataValue>? items = this.topArtistsStream[dateAsString]; + List<Map<String, double>> values = []; + items?.forEach((item) { + values.add({ + item.artistName: item.value, + }); + }); + artistsStreamMap[dateAsString] = values; + }); + return { 'top-artists': listArtists, + 'top-artists-stream-by-date': artistsStreamMap, }; } diff --git a/lib/ui/widgets/abstracts/custom_bar_chart.dart b/lib/ui/widgets/abstracts/custom_bar_chart.dart index e986c84..d80acd1 100644 --- a/lib/ui/widgets/abstracts/custom_bar_chart.dart +++ b/lib/ui/widgets/abstracts/custom_bar_chart.dart @@ -11,7 +11,7 @@ class CustomBarChart extends StatelessWidget { final double chartHeight = 150.0; final double verticalTicksInterval = 10; final String verticalAxisTitleSuffix = ''; - final double titleFontSize = 10; + final double titleFontSize = 9; @override Widget build(BuildContext context) { diff --git a/lib/ui/widgets/abstracts/custom_line_chart.dart b/lib/ui/widgets/abstracts/custom_line_chart.dart index e91e553..e0362d8 100644 --- a/lib/ui/widgets/abstracts/custom_line_chart.dart +++ b/lib/ui/widgets/abstracts/custom_line_chart.dart @@ -7,7 +7,7 @@ class CustomLineChart extends StatelessWidget { const CustomLineChart({super.key}); final double chartHeight = 150.0; - final double titleFontSize = 10; + final double titleFontSize = 9; @override Widget build(BuildContext context) { diff --git a/lib/ui/widgets/cards/top_artists.dart b/lib/ui/widgets/cards/top_artists.dart index 24a8585..c528e43 100644 --- a/lib/ui/widgets/cards/top_artists.dart +++ b/lib/ui/widgets/cards/top_artists.dart @@ -8,6 +8,7 @@ import 'package:scrobbles/models/topartists.dart'; import 'package:scrobbles/network/scrobbles.dart'; import 'package:scrobbles/ui/widgets/card_content.dart'; import 'package:scrobbles/ui/widgets/charts/top_artists.dart'; +import 'package:scrobbles/ui/widgets/charts/top_artists_stream.dart'; import 'package:scrobbles/ui/widgets/error.dart'; class CardTopArtists extends StatelessWidget { @@ -21,6 +22,8 @@ class CardTopArtists extends StatelessWidget { return BlocBuilder<DataTopArtistsCubit, DataTopArtistsState>( builder: (BuildContext context, DataTopArtistsState state) { + TopArtistsData artistsData = state.topArtists ?? TopArtistsData.fromJson({}); + return CardContent( color: Theme.of(context).colorScheme.surface, title: 'top_artists_title'.tr( @@ -29,8 +32,18 @@ class CardTopArtists extends StatelessWidget { }, ), loader: updateTopArtists(daysCount), - content: ChartTopArtists( - chartData: TopArtistsData.fromJson(state.topArtists?.toJson()), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ChartTopArtists( + chartData: artistsData, + ), + const SizedBox(height: 8), + ChartTopArtistsStream( + chartData: artistsData, + ), + ], ), ); }, diff --git a/lib/ui/widgets/charts/top_artists_stream.dart b/lib/ui/widgets/charts/top_artists_stream.dart new file mode 100644 index 0000000..4a5fdf6 --- /dev/null +++ b/lib/ui/widgets/charts/top_artists_stream.dart @@ -0,0 +1,224 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; + +import 'package:scrobbles/config/app_colors.dart'; +import 'package:scrobbles/models/topartists.dart'; +import 'package:scrobbles/ui/widgets/abstracts/custom_line_chart.dart'; +import 'package:scrobbles/utils/color_extensions.dart'; + +class ChartTopArtistsStream extends CustomLineChart { + final TopArtistsData chartData; + + const ChartTopArtistsStream({super.key, required this.chartData}); + + final double verticalTicksInterval = 10; + + @override + Widget build(BuildContext context) { + final horizontalScale = getHorizontalScale(); + + if (this.chartData.topArtistsStream.keys.isEmpty) { + return SizedBox( + height: this.chartHeight, + ); + } + + return Container( + height: this.chartHeight, + child: LineChart( + LineChartData( + lineBarsData: getDataStreamLine(), + betweenBarsData: getBetweenBarsData(), + borderData: getBorderData(), + gridData: getGridData(), + titlesData: getTitlesData(), + lineTouchData: const LineTouchData(enabled: false), + minX: horizontalScale['min'], + maxX: horizontalScale['max'], + maxY: getNextRoundNumber(getMaxVerticalValue(), this.verticalTicksInterval), + minY: 0, + ), + duration: const Duration(milliseconds: 250), + ), + ); + } + + double getNextRoundNumber(double number, double scale) { + return scale * ((number ~/ scale).toInt() + 1); + } + + FlGridData getGridData() { + return const FlGridData( + show: true, + drawHorizontalLine: true, + drawVerticalLine: false, + ); + } + + double getMaxVerticalValue() { + double maxValue = 0; + + this.chartData.topArtistsStream.keys.forEach((dateAsString) { + double totalValue = 0.0; + List<TopArtistsStreamDataValue> artists = + this.chartData.topArtistsStream[dateAsString] ?? []; + + artists.forEach((artist) { + final double value = artist.value; + totalValue = totalValue + value; + }); + + if (totalValue > maxValue) { + maxValue = totalValue; + } + }); + + return maxValue; + } + + Map<String, double> getHorizontalScale() { + double minDateAsDouble = double.maxFinite; + double maxDateAsDouble = -double.maxFinite; + + this.chartData.topArtistsStream.keys.forEach((dateAsString) { + final double date = DateTime.parse(dateAsString).millisecondsSinceEpoch.toDouble(); + + if (date < minDateAsDouble) { + minDateAsDouble = date; + } + if (date > maxDateAsDouble) { + maxDateAsDouble = date; + } + }); + + return { + 'min': minDateAsDouble, + 'max': maxDateAsDouble, + }; + } + + Color getColorIndex(int index) { + const List<int> hexValues = [ + 0x8dd3c7, + 0xffffb3, + 0xbebada, + 0xfb8072, + 0x80b1d3, + 0xfdb462, + 0xb3de69, + 0xfccde5, + 0xd9d9d9, + 0xbc80bd, + 0xccebc5, + 0xffed6f, + ]; + + return Color(hexValues[index % hexValues.length] + 0xff000000); + } + + List<LineChartBarData> getDataStreamLine() { + int artistsCount = + this.chartData.topArtistsStream[this.chartData.topArtistsStream.keys.first]?.length ?? + 0; + + List<LineChartBarData> lines = []; + + LineChartBarData getZeroHorizontalLine() { + final baseColor = getColorIndex(0); + final borderColor = baseColor.darken(20); + + List<FlSpot> spots = []; + + this.chartData.topArtistsStream.keys.forEach((dateAsString) { + final double date = DateTime.parse(dateAsString).millisecondsSinceEpoch.toDouble(); + spots.add(FlSpot(date, 0)); + }); + + return LineChartBarData( + color: borderColor, + dotData: const FlDotData(show: false), + spots: spots, + ); + } + + // First horizontal "zero" line + lines.add(getZeroHorizontalLine()); + + LineChartBarData getLinesFromIndex(int index) { + final baseColor = getColorIndex(index); + final borderColor = baseColor.darken(20); + + List<FlSpot> spots = []; + + this.chartData.topArtistsStream.keys.forEach((dateAsString) { + final double date = DateTime.parse(dateAsString).millisecondsSinceEpoch.toDouble(); + + List<TopArtistsStreamDataValue> artists = + this.chartData.topArtistsStream[dateAsString] ?? []; + + double value = 0; + for (int i = 0; i <= index; i++) { + value = value + artists[i].value; + } + + spots.add(FlSpot(date, value)); + }); + + return LineChartBarData( + isCurved: true, + curveSmoothness: 0.25, + color: borderColor, + dotData: const FlDotData(show: false), + spots: spots, + ); + } + + Iterable<int>.generate(artistsCount) + .toList() + .map((index) => lines.add(getLinesFromIndex(index))) + .toList(); + + return lines; + } + + List<BetweenBarsData> getBetweenBarsData() { + int artistsCount = + this.chartData.topArtistsStream[this.chartData.topArtistsStream.keys.first]?.length ?? + 0; + + return Iterable<int>.generate(artistsCount) + .toList() + .map((index) => BetweenBarsData( + fromIndex: index, + toIndex: index + 1, + color: getColorIndex(index), + )) + .toList(); + } + + Widget getHorizontalTitlesWidget(double value, TitleMeta meta) { + final DateFormat formatter = DateFormat('dd/MM'); + + final DateTime date = DateTime.fromMillisecondsSinceEpoch(value.toInt()); + final String text = formatter.format(date); + + return SideTitleWidget( + axisSide: meta.axisSide, + space: 4, + child: Padding( + padding: EdgeInsets.only(right: 10), + child: RotationTransition( + turns: new AlwaysStoppedAnimation(-30 / 360), + child: Text( + text, + style: TextStyle( + color: AppColors.mainTextColor1, + fontSize: this.titleFontSize, + ), + ), + ), + ), + ); + } +} diff --git a/lib/ui/widgets/error.dart b/lib/ui/widgets/error.dart index e65f242..d09c290 100644 --- a/lib/ui/widgets/error.dart +++ b/lib/ui/widgets/error.dart @@ -8,6 +8,8 @@ class ShowErrorWidget extends StatelessWidget { @override Widget build(BuildContext context) { + print(message); + return Text( 'âš ï¸ ' + tr(message), textAlign: TextAlign.start, diff --git a/pubspec.yaml b/pubspec.yaml index 0b954c9..106d606 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Display scrobbles data and charts publish_to: 'none' -version: 0.0.37+37 +version: 0.0.38+38 environment: sdk: '^3.0.0' -- GitLab