Skip to content
Snippets Groups Projects
Commit 32088ad3 authored by Benoît Harrault's avatar Benoît Harrault
Browse files

Add a day/time heatmap

parent 78624a6d
No related branches found
No related tags found
1 merge request!41Resolve "Add a "day/time" heatmap"
Pipeline #4698 passed
org.gradle.jvmargs=-Xmx1536M org.gradle.jvmargs=-Xmx1536M
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=true
app.versionName=0.0.41 app.versionName=0.0.42
app.versionCode=41 app.versionCode=42
...@@ -17,6 +17,7 @@ ...@@ -17,6 +17,7 @@
"timeline_title": "Recent scrobbles ({daysCount} days)", "timeline_title": "Recent scrobbles ({daysCount} days)",
"counts_by_day": "Counts by day ({daysCount} days)", "counts_by_day": "Counts by day ({daysCount} days)",
"counts_by_hour": "Counts by hour ({daysCount} days)", "counts_by_hour": "Counts by hour ({daysCount} days)",
"heatmap": "Heatmap ({daysCount} days)",
"discoveries_title": "Discoveries ({daysCount} days)", "discoveries_title": "Discoveries ({daysCount} days)",
"discoveries_artists_title": "Artists", "discoveries_artists_title": "Artists",
......
...@@ -17,6 +17,7 @@ ...@@ -17,6 +17,7 @@
"timeline_title": "Écoutes récentes ({daysCount} jours)", "timeline_title": "Écoutes récentes ({daysCount} jours)",
"counts_by_day": "Écoutes par jour ({daysCount} jours)", "counts_by_day": "Écoutes par jour ({daysCount} jours)",
"counts_by_hour": "Écoutes par heure ({daysCount} jours)", "counts_by_hour": "Écoutes par heure ({daysCount} jours)",
"heatmap": "Répartition ({daysCount} jours)",
"discoveries_title": "Découvertes ({daysCount} jours)", "discoveries_title": "Découvertes ({daysCount} jours)",
"discoveries_artists_title": "Artistes", "discoveries_artists_title": "Artistes",
......
Add day/time distribution heatmap.
Ajout du graphique de répartition par jour/heure.
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(),
};
}
}
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,
];
}
...@@ -12,6 +12,7 @@ import 'package:scrobbles/cubit/bottom_nav_cubit.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_day_cubit.dart';
import 'package:scrobbles/cubit/data_counts_by_hour_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_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_global_cubit.dart';
import 'package:scrobbles/cubit/data_statistics_recent_cubit.dart'; import 'package:scrobbles/cubit/data_statistics_recent_cubit.dart';
import 'package:scrobbles/cubit/data_timeline_cubit.dart'; import 'package:scrobbles/cubit/data_timeline_cubit.dart';
...@@ -55,6 +56,7 @@ class MyApp extends StatelessWidget { ...@@ -55,6 +56,7 @@ class MyApp extends StatelessWidget {
BlocProvider<DataCountsByDayCubit>(create: (context) => DataCountsByDayCubit()), BlocProvider<DataCountsByDayCubit>(create: (context) => DataCountsByDayCubit()),
BlocProvider<DataCountsByHourCubit>(create: (context) => DataCountsByHourCubit()), BlocProvider<DataCountsByHourCubit>(create: (context) => DataCountsByHourCubit()),
BlocProvider<DataDiscoveriesCubit>(create: (context) => DataDiscoveriesCubit()), BlocProvider<DataDiscoveriesCubit>(create: (context) => DataDiscoveriesCubit()),
BlocProvider<DataHeatmapCubit>(create: (context) => DataHeatmapCubit()),
BlocProvider<DataStatisticsGlobalCubit>( BlocProvider<DataStatisticsGlobalCubit>(
create: (context) => DataStatisticsGlobalCubit()), create: (context) => DataStatisticsGlobalCubit()),
BlocProvider<DataStatisticsRecentCubit>( BlocProvider<DataStatisticsRecentCubit>(
......
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());
}
}
...@@ -4,6 +4,7 @@ import 'package:http/http.dart' as http; ...@@ -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_day.dart';
import 'package:scrobbles/models/counts_by_hour.dart'; import 'package:scrobbles/models/counts_by_hour.dart';
import 'package:scrobbles/models/discoveries.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_global.dart';
import 'package:scrobbles/models/statistics_recent.dart'; import 'package:scrobbles/models/statistics_recent.dart';
import 'package:scrobbles/models/timeline.dart'; import 'package:scrobbles/models/timeline.dart';
...@@ -88,4 +89,15 @@ class ScrobblesApi { ...@@ -88,4 +89,15 @@ class ScrobblesApi {
throw Exception('Failed to get data from API.'); 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.');
}
}
} }
...@@ -2,6 +2,7 @@ import 'package:flutter/material.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_day.dart';
import 'package:scrobbles/ui/widgets/cards/counts_by_hour.dart'; import 'package:scrobbles/ui/widgets/cards/counts_by_hour.dart';
import 'package:scrobbles/ui/widgets/cards/heatmap.dart';
class ScreenStatistics extends StatelessWidget { class ScreenStatistics extends StatelessWidget {
final Function() notifyParent; final Function() notifyParent;
...@@ -24,6 +25,8 @@ class ScreenStatistics extends StatelessWidget { ...@@ -24,6 +25,8 @@ class ScreenStatistics extends StatelessWidget {
const CardCountsByDay(), const CardCountsByDay(),
const SizedBox(height: 6), const SizedBox(height: 6),
const CardCountsByHour(), const CardCountsByHour(),
const SizedBox(height: 6),
const CardHeatmap(),
const SizedBox(height: 36), const SizedBox(height: 36),
], ],
), ),
......
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;
},
);
},
);
}
}
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,
),
),
);
}
}
...@@ -3,7 +3,7 @@ description: Display scrobbles data and charts ...@@ -3,7 +3,7 @@ description: Display scrobbles data and charts
publish_to: 'none' publish_to: 'none'
version: 0.0.41+41 version: 0.0.42+42
environment: environment:
sdk: '^3.0.0' sdk: '^3.0.0'
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment