From 32088ad399bfd7d69aa446f8467a6ca9a2a60426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Harrault?= <benoit@harrault.fr> Date: Mon, 4 Dec 2023 13:45:48 +0100 Subject: [PATCH] Add a day/time heatmap --- android/gradle.properties | 4 +- assets/translations/en.json | 1 + assets/translations/fr.json | 1 + .../metadata/android/en-US/changelogs/42.txt | 1 + .../metadata/android/fr-FR/changelogs/42.txt | 1 + lib/cubit/data_heatmap_cubit.dart | 45 +++++ lib/cubit/data_heatmap_state.dart | 15 ++ lib/main.dart | 2 + lib/models/heatmap.dart | 47 +++++ lib/network/scrobbles.dart | 12 ++ lib/ui/screens/statistics.dart | 3 + lib/ui/widgets/cards/heatmap.dart | 65 +++++++ lib/ui/widgets/charts/heatmap.dart | 179 ++++++++++++++++++ pubspec.yaml | 2 +- 14 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/42.txt create mode 100644 fastlane/metadata/android/fr-FR/changelogs/42.txt create mode 100644 lib/cubit/data_heatmap_cubit.dart create mode 100644 lib/cubit/data_heatmap_state.dart create mode 100644 lib/models/heatmap.dart create mode 100644 lib/ui/widgets/cards/heatmap.dart create mode 100644 lib/ui/widgets/charts/heatmap.dart diff --git a/android/gradle.properties b/android/gradle.properties index 62205f4..0664a0b 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.41 -app.versionCode=41 +app.versionName=0.0.42 +app.versionCode=42 diff --git a/assets/translations/en.json b/assets/translations/en.json index 3c3161d..f56407b 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -17,6 +17,7 @@ "timeline_title": "Recent scrobbles ({daysCount} days)", "counts_by_day": "Counts by day ({daysCount} days)", "counts_by_hour": "Counts by hour ({daysCount} days)", + "heatmap": "Heatmap ({daysCount} days)", "discoveries_title": "Discoveries ({daysCount} days)", "discoveries_artists_title": "Artists", diff --git a/assets/translations/fr.json b/assets/translations/fr.json index 7870ef8..f9d2d14 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -17,6 +17,7 @@ "timeline_title": "Écoutes récentes ({daysCount} jours)", "counts_by_day": "Écoutes par jour ({daysCount} jours)", "counts_by_hour": "Écoutes par heure ({daysCount} jours)", + "heatmap": "Répartition ({daysCount} jours)", "discoveries_title": "Découvertes ({daysCount} jours)", "discoveries_artists_title": "Artistes", diff --git a/fastlane/metadata/android/en-US/changelogs/42.txt b/fastlane/metadata/android/en-US/changelogs/42.txt new file mode 100644 index 0000000..711bbbf --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/42.txt @@ -0,0 +1 @@ +Add day/time distribution heatmap. diff --git a/fastlane/metadata/android/fr-FR/changelogs/42.txt b/fastlane/metadata/android/fr-FR/changelogs/42.txt new file mode 100644 index 0000000..2dd7878 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/42.txt @@ -0,0 +1 @@ +Ajout du graphique de répartition par jour/heure. diff --git a/lib/cubit/data_heatmap_cubit.dart b/lib/cubit/data_heatmap_cubit.dart new file mode 100644 index 0000000..646ba62 --- /dev/null +++ b/lib/cubit/data_heatmap_cubit.dart @@ -0,0 +1,45 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:hydrated_bloc/hydrated_bloc.dart'; + +import 'package:scrobbles/models/heatmap.dart'; + +part 'data_heatmap_state.dart'; + +class DataHeatmapCubit extends HydratedCubit<DataHeatmapState> { + DataHeatmapCubit() : super(const DataHeatmapState()); + + void getData(DataHeatmapState state) { + emit(state); + } + + HeatmapData? getValue() { + return state.heatmap; + } + + void update(HeatmapData? heatmapData) { + if ((heatmapData != null) && (state.heatmap.toString() != heatmapData.toString())) { + setValue(heatmapData); + } + } + + void setValue(HeatmapData? heatmapData) { + emit(DataHeatmapState( + heatmap: heatmapData, + )); + } + + @override + DataHeatmapState? fromJson(Map<String, dynamic> json) { + return DataHeatmapState( + heatmap: HeatmapData.fromJson(json['heatmap']), + ); + } + + @override + Map<String, Object?>? toJson(DataHeatmapState state) { + return <String, Object?>{ + 'heatmap': state.heatmap?.toJson(), + }; + } +} diff --git a/lib/cubit/data_heatmap_state.dart b/lib/cubit/data_heatmap_state.dart new file mode 100644 index 0000000..0814a3b --- /dev/null +++ b/lib/cubit/data_heatmap_state.dart @@ -0,0 +1,15 @@ +part of 'data_heatmap_cubit.dart'; + +@immutable +class DataHeatmapState extends Equatable { + const DataHeatmapState({ + this.heatmap, + }); + + final HeatmapData? heatmap; + + @override + List<Object?> get props => <Object?>[ + heatmap, + ]; +} diff --git a/lib/main.dart b/lib/main.dart index d4ca87c..d5123a1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,6 +12,7 @@ import 'package:scrobbles/cubit/bottom_nav_cubit.dart'; import 'package:scrobbles/cubit/data_counts_by_day_cubit.dart'; import 'package:scrobbles/cubit/data_counts_by_hour_cubit.dart'; import 'package:scrobbles/cubit/data_discoveries_cubit.dart'; +import 'package:scrobbles/cubit/data_heatmap_cubit.dart'; import 'package:scrobbles/cubit/data_statistics_global_cubit.dart'; import 'package:scrobbles/cubit/data_statistics_recent_cubit.dart'; import 'package:scrobbles/cubit/data_timeline_cubit.dart'; @@ -55,6 +56,7 @@ class MyApp extends StatelessWidget { BlocProvider<DataCountsByDayCubit>(create: (context) => DataCountsByDayCubit()), BlocProvider<DataCountsByHourCubit>(create: (context) => DataCountsByHourCubit()), BlocProvider<DataDiscoveriesCubit>(create: (context) => DataDiscoveriesCubit()), + BlocProvider<DataHeatmapCubit>(create: (context) => DataHeatmapCubit()), BlocProvider<DataStatisticsGlobalCubit>( create: (context) => DataStatisticsGlobalCubit()), BlocProvider<DataStatisticsRecentCubit>( diff --git a/lib/models/heatmap.dart b/lib/models/heatmap.dart new file mode 100644 index 0000000..a29537b --- /dev/null +++ b/lib/models/heatmap.dart @@ -0,0 +1,47 @@ +import 'dart:convert'; + +class HeatmapData { + final Map<int, Map<int, int>> data; + + const HeatmapData({ + required this.data, + }); + + factory HeatmapData.fromJson(Map<String, dynamic>? json) { + Map<int, Map<int, int>> data = {}; + + if (json?['heatmap'] != null) { + json?['heatmap'].keys.forEach((day) { + Map<String, dynamic> rawDataForThisDay = json['heatmap'][day]; + + Map<int, int> dataForThisDay = {}; + rawDataForThisDay.keys.forEach((hour) { + dataForThisDay[int.parse(hour)] = int.parse(rawDataForThisDay[hour].toString()); + }); + + data[int.parse(day)] = dataForThisDay; + }); + } + + return HeatmapData(data: data); + } + + Map<String, dynamic> toJson() { + Map<String, Map<String, int>> map = {}; + + this.data.keys.forEach((day) { + Map<String, int> dayMap = {}; + this.data.keys.forEach((hour) { + int? value = this.data[day]?[hour]; + dayMap[hour.toString()] = value != null ? value.toInt() : 0; + }); + map[day.toString()] = dayMap; + }); + + return {'heatmap': map}; + } + + String toString() { + return jsonEncode(this.toJson()); + } +} diff --git a/lib/network/scrobbles.dart b/lib/network/scrobbles.dart index 7da32f9..7f5c6f9 100644 --- a/lib/network/scrobbles.dart +++ b/lib/network/scrobbles.dart @@ -4,6 +4,7 @@ import 'package:http/http.dart' as http; import 'package:scrobbles/models/counts_by_day.dart'; import 'package:scrobbles/models/counts_by_hour.dart'; import 'package:scrobbles/models/discoveries.dart'; +import 'package:scrobbles/models/heatmap.dart'; import 'package:scrobbles/models/statistics_global.dart'; import 'package:scrobbles/models/statistics_recent.dart'; import 'package:scrobbles/models/timeline.dart'; @@ -88,4 +89,15 @@ class ScrobblesApi { throw Exception('Failed to get data from API.'); } } + + static Future<HeatmapData> fetchHeatmap(int daysCount) async { + final String url = baseUrl + '/data/' + daysCount.toString() + '/heatmap'; + final response = await http.get(Uri.parse(url)); + + if (response.statusCode == 200) { + return HeatmapData.fromJson(jsonDecode(response.body) as Map<String, dynamic>); + } else { + throw Exception('Failed to get data from API.'); + } + } } diff --git a/lib/ui/screens/statistics.dart b/lib/ui/screens/statistics.dart index 1dfffd1..55e3fe9 100644 --- a/lib/ui/screens/statistics.dart +++ b/lib/ui/screens/statistics.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:scrobbles/ui/widgets/cards/counts_by_day.dart'; import 'package:scrobbles/ui/widgets/cards/counts_by_hour.dart'; +import 'package:scrobbles/ui/widgets/cards/heatmap.dart'; class ScreenStatistics extends StatelessWidget { final Function() notifyParent; @@ -24,6 +25,8 @@ class ScreenStatistics extends StatelessWidget { const CardCountsByDay(), const SizedBox(height: 6), const CardCountsByHour(), + const SizedBox(height: 6), + const CardHeatmap(), const SizedBox(height: 36), ], ), diff --git a/lib/ui/widgets/cards/heatmap.dart b/lib/ui/widgets/cards/heatmap.dart new file mode 100644 index 0000000..06b5a0a --- /dev/null +++ b/lib/ui/widgets/cards/heatmap.dart @@ -0,0 +1,65 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:scrobbles/cubit/data_heatmap_cubit.dart'; +import 'package:scrobbles/cubit/settings_cubit.dart'; +import 'package:scrobbles/models/heatmap.dart'; +import 'package:scrobbles/network/scrobbles.dart'; +import 'package:scrobbles/ui/widgets/card_content.dart'; +import 'package:scrobbles/ui/widgets/charts/heatmap.dart'; +import 'package:scrobbles/ui/widgets/error.dart'; + +class CardHeatmap extends StatelessWidget { + const CardHeatmap({super.key}); + + @override + Widget build(BuildContext context) { + SettingsCubit settings = BlocProvider.of<SettingsCubit>(context); + + final int daysCount = settings.getDistributionDaysCount(); + + return BlocBuilder<DataHeatmapCubit, DataHeatmapState>( + builder: (BuildContext context, DataHeatmapState state) { + HeatmapData heatmap = state.heatmap ?? HeatmapData.fromJson({}); + + return CardContent( + color: Theme.of(context).colorScheme.surface, + title: 'heatmap'.tr( + namedArgs: { + 'daysCount': daysCount.toString(), + }, + ), + loader: updateCountsByHour(daysCount), + content: ChartHeatmap( + chartData: heatmap, + ), + ); + }, + ); + } + + Widget updateCountsByHour(int daysCount) { + final Widget loading = const Text('â³'); + final Widget done = const Text(''); + + late Future<HeatmapData> futureHeatmap = ScrobblesApi.fetchHeatmap(daysCount); + + return BlocBuilder<DataHeatmapCubit, DataHeatmapState>( + builder: (BuildContext context, DataHeatmapState state) { + return FutureBuilder<HeatmapData>( + future: futureHeatmap, + builder: (context, snapshot) { + if (snapshot.hasError) { + return ShowErrorWidget(message: '${snapshot.error}'); + } + + BlocProvider.of<DataHeatmapCubit>(context).update(snapshot.data); + + return !snapshot.hasData ? loading : done; + }, + ); + }, + ); + } +} diff --git a/lib/ui/widgets/charts/heatmap.dart b/lib/ui/widgets/charts/heatmap.dart new file mode 100644 index 0000000..1f4874e --- /dev/null +++ b/lib/ui/widgets/charts/heatmap.dart @@ -0,0 +1,179 @@ +import 'dart:math'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:scrobbles/config/app_colors.dart'; + +import 'package:scrobbles/models/heatmap.dart'; +import 'package:scrobbles/utils/color_extensions.dart'; + +class ChartHeatmap extends StatelessWidget { + final HeatmapData chartData; + + const ChartHeatmap({super.key, required this.chartData}); + + final double chartHeight = 150.0; + final double titleFontSize = 9; + final Color baseColor = AppColors.contentColorPink; + final double scale = 8.0; + final double darkenAmount = 50; + + @override + Widget build(BuildContext context) { + if (this.chartData.data.keys.length == 0) { + return SizedBox( + height: this.chartHeight, + ); + } + + return AspectRatio( + aspectRatio: 3, + child: ScatterChart( + ScatterChartData( + scatterSpots: getSpots(), + minX: 0, + maxX: 24, + minY: 0, + maxY: 7, + borderData: FlBorderData(show: false), + gridData: FlGridData(show: false), + titlesData: getTitlesData(), + scatterTouchData: ScatterTouchData(enabled: false), + ), + ), + ); + } + + Color getColorFromNormalizedValue(double value) { + return baseColor.darken(1 + (darkenAmount * (1 - value)).toInt()); + } + + int getMaxCount() { + int maxValue = 0; + + this.chartData.data.forEach((day, hours) { + hours.forEach((hour, count) { + if (count > maxValue) { + maxValue = count; + } + }); + }); + + return maxValue; + } + + List<ScatterSpot> getSpots() { + List<ScatterSpot> spots = []; + + final int maxCount = getMaxCount(); + + this.chartData.data.forEach((day, hours) { + hours.forEach((hour, count) { + double normalizedValue = count / maxCount; + + spots.add(ScatterSpot( + hour.toDouble(), + day.toDouble(), + color: getColorFromNormalizedValue(normalizedValue), + radius: scale * normalizedValue, + )); + }); + }); + + return spots; + } + + FlTitlesData getTitlesData() { + const AxisTitles none = const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ); + + final AxisTitles verticalTitles = AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + getTitlesWidget: getVerticalTitlesWidget, + interval: 1, + ), + ); + + final AxisTitles horizontalTitles = AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 20, + getTitlesWidget: getHorizontalTitlesWidget, + interval: 4, + ), + ); + + return FlTitlesData( + show: true, + bottomTitles: horizontalTitles, + leftTitles: verticalTitles, + topTitles: none, + rightTitles: none, + ); + } + + Widget getVerticalTitlesWidget(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: + } + + return SideTitleWidget( + axisSide: meta.axisSide, + space: 10, + child: Text( + tr(dayShortName), + style: TextStyle( + color: AppColors.mainTextColor1, + fontSize: this.titleFontSize, + ), + ), + ); + } + + Widget getHorizontalTitlesWidget(double value, TitleMeta meta) { + String text = ''; + + if (value % 4 == 0 || value == 23) { + text = value.toInt().toString().padLeft(2, '0'); + } + + return SideTitleWidget( + axisSide: meta.axisSide, + space: 2, + child: Text( + text, + style: TextStyle( + color: AppColors.mainTextColor1, + fontSize: this.titleFontSize, + ), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 8af3ab6..9879f9a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Display scrobbles data and charts publish_to: 'none' -version: 0.0.41+41 +version: 0.0.42+42 environment: sdk: '^3.0.0' -- GitLab