diff --git a/android/gradle.properties b/android/gradle.properties index 135006f9c1386c8757595c43e890e911f732f5a3..85b94f88ee157e1d1b3cec184c8948902443d36f 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.6 -app.versionCode=6 +app.versionName=0.0.7 +app.versionCode=7 diff --git a/assets/translations/en.json b/assets/translations/en.json index 31c1591ed59c198141087871b3ed62adb9be4c49..179e72fda547299ecd00d8e23f5e484972e8bcbf 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -6,5 +6,7 @@ "statistics_last_scrobble": "Last scrobble: {datetime}", "statistics_selected_period": "On last {daysCount} days:", "statistics_recent_scrobbles_count": "Scrobbles: {count}", - "statistics_discoveries": "Discoveries: {artistsCount} artists / {tracksCount} tracks" + "statistics_discoveries": "Discoveries: {artistsCount} artists / {tracksCount} tracks", + + "timeline_title": "Recent scrobbles ({daysCount} days)" } diff --git a/assets/translations/fr.json b/assets/translations/fr.json index e1594f241cbdbce2d95c48c582aefe5e038900c5..064f98c9200cedb03f3816b00db07589ac1910e5 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -6,5 +6,7 @@ "statistics_last_scrobble": "Dernière écoute : {datetime}", "statistics_selected_period": "Sur les {daysCount} derniers jours:", "statistics_recent_scrobbles_count": "Écoutes : {count}", - "statistics_discoveries": "Découvertes : {artistsCount} artistes / {tracksCount} morceaux" + "statistics_discoveries": "Découvertes : {artistsCount} artistes / {tracksCount} morceaux", + + "timeline_title": "Écoutes récentes ({daysCount} jours)" } diff --git a/fastlane/metadata/android/en-US/changelogs/7.txt b/fastlane/metadata/android/en-US/changelogs/7.txt new file mode 100644 index 0000000000000000000000000000000000000000..2dbc251e1072618129a60896eda44c63f551bd6a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/7.txt @@ -0,0 +1 @@ +Add scrobbles counts main chart. diff --git a/fastlane/metadata/android/fr-FR/changelogs/7.txt b/fastlane/metadata/android/fr-FR/changelogs/7.txt new file mode 100644 index 0000000000000000000000000000000000000000..9f2a817ba94b0953732751034190158701bfbd58 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/7.txt @@ -0,0 +1 @@ +Ajout du graphique principal de compteur d'écoutes. diff --git a/lib/config/app_colors.dart b/lib/config/app_colors.dart new file mode 100644 index 0000000000000000000000000000000000000000..a1ca589a1cef364687bbb2ee76e4073ce17ea650 --- /dev/null +++ b/lib/config/app_colors.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +class AppColors { + static const Color primary = contentColorCyan; + static const Color menuBackground = Color(0xFF090912); + static const Color itemsBackground = Color(0xFF1B2339); + static const Color pageBackground = Color(0xFF282E45); + static const Color mainTextColor1 = Colors.white; + static const Color mainTextColor2 = Colors.white70; + static const Color mainTextColor3 = Colors.white38; + static const Color mainGridLineColor = Colors.white10; + static const Color borderColor = Colors.white54; + static const Color gridLinesColor = Color(0x11FFFFFF); + + static const Color contentColorBlack = Colors.black; + static const Color contentColorWhite = Colors.white; + static const Color contentColorBlue = Color(0xFF2196F3); + static const Color contentColorYellow = Color(0xFFFFC300); + static const Color contentColorOrange = Color(0xFFFF683B); + static const Color contentColorGreen = Color(0xFF3BFF49); + static const Color contentColorPurple = Color(0xFF6E1BFF); + static const Color contentColorPink = Color(0xFFFF3AF2); + static const Color contentColorRed = Color(0xFFE80054); + static const Color contentColorCyan = Color(0xFF50E4FF); +} diff --git a/lib/models/timeline.dart b/lib/models/timeline.dart new file mode 100644 index 0000000000000000000000000000000000000000..15042338609249c93bf68f395d11f0bba4c68838 --- /dev/null +++ b/lib/models/timeline.dart @@ -0,0 +1,56 @@ +import 'dart:convert'; + +class TimelineDataValue { + final int counts; + final int eclecticism; + + const TimelineDataValue({required this.counts, required this.eclecticism}); + + factory TimelineDataValue.fromJson(Map<String, dynamic> json) { + return TimelineDataValue( + counts: json['counts'] as int, + eclecticism: json['eclecticism'] as int, + ); + } +} + +class TimelineData { + final Map<String, TimelineDataValue> data; + + const TimelineData({ + required this.data, + }); + + factory TimelineData.fromJson(Map<String, dynamic> json) { + Map<String, TimelineDataValue> data = {}; + + json.keys.forEach((date) { + TimelineDataValue value = TimelineDataValue( + counts: json[date]['counts'] as int, + eclecticism: json[date]['eclecticism'] as int, + ); + + data[date] = value; + }); + + return TimelineData(data: data); + } + + factory TimelineData.createEmpty() { + return TimelineData.fromJson({}); + } + + String toString() { + Map<String, Map<String, int>> map = {}; + + this.data.keys.forEach((element) { + TimelineDataValue? item = this.data[element]; + map[element] = { + 'counts': item != null ? item.counts : 0, + 'eclecticism': item != null ? item.eclecticism : 0, + }; + }); + + return jsonEncode(map); + } +} diff --git a/lib/network/scrobbles_api.dart b/lib/network/scrobbles_api.dart index 102a8175f7436126cbc1c1e6a8860482ec363b21..64017d096c4c2b6f802b5bef01a5506400ee427d 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/statistics.dart'; +import '../models/timeline.dart'; class ScrobblesApi { static String baseUrl = 'https://scrobble.harrault.fr'; @@ -15,4 +16,15 @@ class ScrobblesApi { throw Exception('Failed to get data from API.'); } } + + static Future<TimelineData> fetchTimeline(int daysCount) async { + final String url = baseUrl + '/data/' + daysCount.toString() + '/timeline'; + final response = await http.get(Uri.parse(url)); + + if (response.statusCode == 200) { + return TimelineData.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 94275cada3765bcf94b20b710fec33ad35a632a9..17f0fb5a39dad07eae8f664e4385abd39fc163d9 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/statistics_card.dart'; +import '../widgets/main_screen/timeline_card.dart'; class MainScreen extends StatelessWidget { const MainScreen({super.key}); @@ -16,6 +17,8 @@ class MainScreen extends StatelessWidget { children: <Widget>[ const Header(text: 'app_name'), const StatisticsCard(), + const SizedBox(height: 8), + const ChartTimelineCard(), const SizedBox(height: 36), ], ), diff --git a/lib/ui/widgets/main_screen/timeline_card.dart b/lib/ui/widgets/main_screen/timeline_card.dart new file mode 100644 index 0000000000000000000000000000000000000000..bd3d14a8fc6f86452534d98ea0191aa2ac585198 --- /dev/null +++ b/lib/ui/widgets/main_screen/timeline_card.dart @@ -0,0 +1,48 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +import '../../../models/timeline.dart'; +import '../../../network/scrobbles_api.dart'; +import '../../../ui/widgets/error.dart'; +import '../../../ui/widgets/main_screen/timeline_content.dart'; + +class ChartTimelineCard extends StatelessWidget { + const ChartTimelineCard({super.key}); + + @override + Widget build(BuildContext context) { + final int daysCount = 14; + late Future<TimelineData> futureTimeline = ScrobblesApi.fetchTimeline(daysCount); + + return FutureBuilder<TimelineData>( + future: futureTimeline, + 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: ChartTimelineCardContent( + daysCount: daysCount, + chartData: snapshot.hasData + ? TimelineData.fromJson(jsonDecode(snapshot.data.toString())) + : TimelineData.createEmpty(), + isLoading: !snapshot.hasData, + ), + ), + ); + }, + ); + } +} diff --git a/lib/ui/widgets/main_screen/timeline_chart_counts.dart b/lib/ui/widgets/main_screen/timeline_chart_counts.dart new file mode 100644 index 0000000000000000000000000000000000000000..409d7dc19712e72c3a66a947146146ef3c4fd5ca --- /dev/null +++ b/lib/ui/widgets/main_screen/timeline_chart_counts.dart @@ -0,0 +1,193 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; + +import '../../../config/app_colors.dart'; +import '../../../models/timeline.dart'; +import '../../../utils/color_extensions.dart'; + +class ChartTimelineCounts extends StatelessWidget { + final TimelineData chartData; + + const ChartTimelineCounts({super.key, required this.chartData}); + + @override + Widget build(BuildContext context) { + return Container( + height: 150.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(), 50), + ), + ); + }), + ); + } + + double getMaxCountsValue() { + double maxValue = 0; + + this.chartData.data.keys.forEach((key) { + TimelineDataValue? value = this.chartData.data[key]; + if (value != null) { + double counts = value.counts.toDouble(); + 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((key) { + TimelineDataValue? value = this.chartData.data[key]; + if (value != null) { + final int date = DateTime.parse(key).millisecondsSinceEpoch; + final double counts = value.counts.toDouble(); + + data.add(BarChartGroupData( + x: date, + barRods: [ + BarChartRodData( + toY: counts, + color: AppColors.contentColorOrange, + width: barWidth, + borderRadius: BorderRadius.all(Radius.zero), + borderSide: BorderSide( + color: AppColors.contentColorOrange.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, + ); + } + + FlTitlesData getTitlesData() { + const AxisTitles none = const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ); + + final AxisTitles verticalTitles = AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 30, + getTitlesWidget: getVerticalTitlesWidget, + ), + ); + 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) { + 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: RotationTransition( + turns: new AlwaysStoppedAnimation(-30 / 360), + 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/timeline_content.dart b/lib/ui/widgets/main_screen/timeline_content.dart new file mode 100644 index 0000000000000000000000000000000000000000..747a752ae9c2d1fa7f645ddb415887c13ffe6c6b --- /dev/null +++ b/lib/ui/widgets/main_screen/timeline_content.dart @@ -0,0 +1,49 @@ +import 'dart:convert'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import '../../../models/timeline.dart'; +import 'timeline_chart_counts.dart'; + +class ChartTimelineCardContent extends StatelessWidget { + final int daysCount; + final TimelineData chartData; + final bool isLoading; + + const ChartTimelineCardContent( + {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( + 'timeline_title', + style: textTheme.titleLarge!.apply(fontWeightDelta: 2), + ).tr( + namedArgs: { + 'daysCount': this.daysCount.toString(), + }, + ), + const SizedBox(height: 8), + this.isLoading + ? Text(placeholder) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ChartTimelineCounts( + chartData: TimelineData.fromJson(jsonDecode(this.chartData.toString())), + ), + ], + ), + ], + ); + } +} diff --git a/lib/utils/color_extensions.dart b/lib/utils/color_extensions.dart new file mode 100644 index 0000000000000000000000000000000000000000..4e55e338f0d3ed98b233d1ef887b7b3e17e29d97 --- /dev/null +++ b/lib/utils/color_extensions.dart @@ -0,0 +1,33 @@ +import 'dart:ui'; + +extension ColorExtension on Color { + Color darken([int percent = 40]) { + assert(1 <= percent && percent <= 100); + final value = 1 - percent / 100; + return Color.fromARGB( + alpha, + (red * value).round(), + (green * value).round(), + (blue * value).round(), + ); + } + + Color lighten([int percent = 40]) { + assert(1 <= percent && percent <= 100); + final value = percent / 100; + return Color.fromARGB( + alpha, + (red + ((255 - red) * value)).round(), + (green + ((255 - green) * value)).round(), + (blue + ((255 - blue) * value)).round(), + ); + } + + Color avg(Color other) { + final red = (this.red + other.red) ~/ 2; + final green = (this.green + other.green) ~/ 2; + final blue = (this.blue + other.blue) ~/ 2; + final alpha = (this.alpha + other.alpha) ~/ 2; + return Color.fromARGB(alpha, red, green, blue); + } +} diff --git a/pubspec.lock b/pubspec.lock index b999423b57d2df0826c5e29ad20d24a920e6bd85..6952c80f4084d5c3cf81e39ae9fab6f9d59bebb9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.2" + equatable: + dependency: transitive + description: + name: equatable + sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 + url: "https://pub.dev" + source: hosted + version: "2.0.5" ffi: dependency: transitive description: @@ -73,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: "6b9eb2b3017241d05c482c01f668dd05cc909ec9a0114fdd49acd958ff2432fa" + url: "https://pub.dev" + source: hosted + version: "0.64.0" flutter: dependency: "direct main" description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 3443b82105e3ebc8fb98e60444b5695ef31fa726..2439c6dec5fed740a8901704764de77dda3cfe25 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Display scrobbles data and charts publish_to: 'none' -version: 0.0.6+6 +version: 0.0.7+7 environment: sdk: '^3.0.0' @@ -14,6 +14,7 @@ dependencies: easy_localization: ^3.0.1 http: ^1.1.0 + fl_chart: ^0.64.0 flutter: uses-material-design: false