diff --git a/android/gradle.properties b/android/gradle.properties index 4bb5439f682100f8ef4ba80a557fe4f2f0ab14c2..6bf54a6ed821c19f76d860d4a24e7c85d440b575 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.9 -app.versionCode=9 +app.versionName=0.0.10 +app.versionCode=10 diff --git a/assets/translations/en.json b/assets/translations/en.json index 8c5b323755d16c435eac1201cf9fbb78e4bb91b2..708e712862d32d3fcfa2e45b575a0298303a1bdc 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -10,6 +10,8 @@ "timeline_title": "Recent scrobbles ({daysCount} days)", "counts_by_day": "Counts by day ({daysCount} days)", + "counts_by_hour": "Counts by hour ({daysCount} days)", + "MON": "MON", "TUE": "TUE", "WED": "WED", diff --git a/assets/translations/fr.json b/assets/translations/fr.json index a577ce73c0f161fe8297fc3667d41a4a0a314748..4394698622d36ddf4d04c373aaf341256ab824e9 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -10,6 +10,8 @@ "timeline_title": "Écoutes récentes ({daysCount} jours)", "counts_by_day": "Écoutes par jour ({daysCount} jours)", + "counts_by_hour": "Écoutes par heure ({daysCount} jours)", + "MON": "LUN", "TUE": "MAR", "WED": "MER", diff --git a/fastlane/metadata/android/en-US/changelogs/10.txt b/fastlane/metadata/android/en-US/changelogs/10.txt new file mode 100644 index 0000000000000000000000000000000000000000..eb0128d035ecdea3ae9c51fa5e3f6fdb75088d00 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/10.txt @@ -0,0 +1 @@ +Add chart "scrobbles counts per hour". diff --git a/fastlane/metadata/android/fr-FR/changelogs/10.txt b/fastlane/metadata/android/fr-FR/changelogs/10.txt new file mode 100644 index 0000000000000000000000000000000000000000..6a86d7e679278b35b8ea0f78027a110f2753de8a --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/10.txt @@ -0,0 +1 @@ +Ajout du graphique du nombre d'écoutes par heure. diff --git a/lib/models/counts_by_hour.dart b/lib/models/counts_by_hour.dart new file mode 100644 index 0000000000000000000000000000000000000000..12857528cec1ac7a6dc8af6141a349b9cba11db8 --- /dev/null +++ b/lib/models/counts_by_hour.dart @@ -0,0 +1,38 @@ +import 'dart:convert'; + +class CountsByHourData { + final Map<int, double> data; + + const CountsByHourData({ + required this.data, + }); + + factory CountsByHourData.fromJson(Map<String, dynamic> json) { + Map<int, double> data = {}; + + if (json['counts-by-hour'] != null) { + json['counts-by-hour'].keys.forEach((day) { + if (int.parse(day) != 24) { + data[int.parse(day)] = double.parse(json['counts-by-hour'][day].toString()); + } + }); + } + + return CountsByHourData(data: data); + } + + factory CountsByHourData.createEmpty() { + return CountsByHourData.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-hour': map}); + } +} diff --git a/lib/network/scrobbles_api.dart b/lib/network/scrobbles_api.dart index b30cb39e33cff33dddf148b47c6cbf63605d4983..7b0035916a3f32d3cc0788752620346443c6e720 100644 --- a/lib/network/scrobbles_api.dart +++ b/lib/network/scrobbles_api.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import '../models/counts_by_day.dart'; +import '../models/counts_by_hour.dart'; import '../models/statistics.dart'; import '../models/timeline.dart'; @@ -39,4 +40,15 @@ class ScrobblesApi { throw Exception('Failed to get data from API.'); } } + + static Future<CountsByHourData> fetchCountsByHour(int daysCount) async { + final String url = baseUrl + '/data/' + daysCount.toString() + '/counts-by-hour'; + final response = await http.get(Uri.parse(url)); + + if (response.statusCode == 200) { + return CountsByHourData.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 1227308233de0f732dcc3018fca9ec7e6959f504..8ac7cca5d32481b1439bb75d35433530fbdd66ca 100644 --- a/lib/ui/screens/main_screen.dart +++ b/lib/ui/screens/main_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../widgets/header.dart'; import '../widgets/main_screen/counts_by_day_card.dart'; +import '../widgets/main_screen/counts_by_hour_card.dart'; import '../widgets/main_screen/statistics_card.dart'; import '../widgets/main_screen/timeline_card.dart'; @@ -22,6 +23,8 @@ class MainScreen extends StatelessWidget { const ChartTimelineCard(), const SizedBox(height: 8), const ChartCountsByDayCard(), + const SizedBox(height: 8), + const ChartCountsByHourCard(), const SizedBox(height: 36), ], ), diff --git a/lib/ui/widgets/main_screen/counts_by_hour_card.dart b/lib/ui/widgets/main_screen/counts_by_hour_card.dart new file mode 100644 index 0000000000000000000000000000000000000000..4c030f7ddb406b5ebd4037b225e171bdeb6c1304 --- /dev/null +++ b/lib/ui/widgets/main_screen/counts_by_hour_card.dart @@ -0,0 +1,49 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +import '../../../models/counts_by_hour.dart'; +import '../../../network/scrobbles_api.dart'; +import '../../../ui/widgets/error.dart'; +import '../../../ui/widgets/main_screen/counts_by_hour_content.dart'; + +class ChartCountsByHourCard extends StatelessWidget { + const ChartCountsByHourCard({super.key}); + + @override + Widget build(BuildContext context) { + final int daysCount = 21; + late Future<CountsByHourData> futureCountsByHour = + ScrobblesApi.fetchCountsByHour(daysCount); + + return FutureBuilder<CountsByHourData>( + future: futureCountsByHour, + 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: ChartCountsByHourCardContent( + daysCount: daysCount, + chartData: snapshot.hasData + ? CountsByHourData.fromJson(jsonDecode(snapshot.data.toString())) + : CountsByHourData.createEmpty(), + isLoading: !snapshot.hasData, + ), + ), + ); + }, + ); + } +} diff --git a/lib/ui/widgets/main_screen/counts_by_hour_chart.dart b/lib/ui/widgets/main_screen/counts_by_hour_chart.dart new file mode 100644 index 0000000000000000000000000000000000000000..b8154fcb7ac458718fe507aa076f03121bc4beb5 --- /dev/null +++ b/lib/ui/widgets/main_screen/counts_by_hour_chart.dart @@ -0,0 +1,192 @@ +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; + +import '../../../config/app_colors.dart'; +import '../../../models/counts_by_hour.dart'; +import '../../../utils/color_extensions.dart'; + +class CountsByHourCardContentChart extends StatelessWidget { + final CountsByHourData chartData; + + const CountsByHourCardContentChart({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(), 5), + ), + ); + }), + ); + } + + 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) { + final Color barColor = AppColors.contentColorCyan.darken(30); + + 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, + drawHorizontalLine: true, + drawVerticalLine: false, + ); + } + + FlTitlesData getTitlesData() { + const AxisTitles none = const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ); + + final AxisTitles verticalTitles = AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 30, + getTitlesWidget: getVerticalTitlesWidget, + interval: 5, + ), + ); + 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) { + 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: 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_hour_content.dart b/lib/ui/widgets/main_screen/counts_by_hour_content.dart new file mode 100644 index 0000000000000000000000000000000000000000..d53c34fcd8a3f0ecd994296d559564bbb63df351 --- /dev/null +++ b/lib/ui/widgets/main_screen/counts_by_hour_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_hour.dart'; +import '../../../ui/widgets/main_screen/counts_by_hour_chart.dart'; + +class ChartCountsByHourCardContent extends StatelessWidget { + final int daysCount; + final CountsByHourData chartData; + final bool isLoading; + + const ChartCountsByHourCardContent( + {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_hour', + style: textTheme.titleLarge!.apply(fontWeightDelta: 2), + ).tr( + namedArgs: { + 'daysCount': this.daysCount.toString(), + }, + ), + const SizedBox(height: 8), + this.isLoading + ? Text(placeholder) + : CountsByHourCardContentChart( + chartData: CountsByHourData.fromJson(jsonDecode(this.chartData.toString())), + ), + ], + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index c2888d4362d054a8edf8d81c486e5e9bb4f23fd6..27e3de8e8f735ed1cc6fe6a38173279eaf6daaf8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Display scrobbles data and charts publish_to: 'none' -version: 0.0.9+9 +version: 0.0.10+10 environment: sdk: '^3.0.0'