diff --git a/android/gradle.properties b/android/gradle.properties index 62205f40150696555e74bed7fbf2f63d6f99f49b..0664a0bd52bb87641404da26292a8bbba0c4153b 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 3c3161ddd6fdce8e232a174f94291efbdcacd95f..f56407bb0e040ae843bd5078573116c48885a23e 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 7870ef831dea5485266e868ecb1552f28bf71c89..f9d2d14591516b4a112a53284bf1864eb5345744 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 0000000000000000000000000000000000000000..711bbbfd94446e694b48160adff8323320d165a9 --- /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 0000000000000000000000000000000000000000..2dd7878b7e337e233947ba2ae309f74f6fa7ea8f --- /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 0000000000000000000000000000000000000000..646ba623036421f8a97eccda0c9f642e0bf10d2a --- /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 0000000000000000000000000000000000000000..0814a3bd507987f5a7bd0c5a3178a9c767ed4bc2 --- /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 d4ca87cf3c8e94138b5fa45e851b5c143fa6ce1f..d5123a18c168b1c89afbbea128aa1a11e5924935 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 0000000000000000000000000000000000000000..a29537b8127ed0205e7476dd45b648ac19af599b --- /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 7da32f94bc30b36644f40d1dc94b3565b0877e98..7f5c6f96f0276d7386147cf6197b34fd29ad1f27 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 1dfffd1cb85cdf5680169cca761a5195e1779cb3..55e3fe9c5e9953e4da424240398b2125b0f94874 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 0000000000000000000000000000000000000000..06b5a0ac9ba6a68a8a65c9bb39ac6ff4f046ac43 --- /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 0000000000000000000000000000000000000000..1f4874ee6673fe3110a47c7ab69feb0cc65c5ff8 --- /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 8af3ab653348dc325ed678895f8b7d517e76f93a..9879f9ac4bc0b6d03348bdafdbb402302bbd9ffe 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'