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

Get data from API and display main timeline scrobbles chart

parent d8a440e2
No related branches found
No related tags found
1 merge request!4Resolve "Get data from API and display charts"
Pipeline #4438 passed
This commit is part of merge request !4. Comments created here will be created in the context of that merge request.
Showing with 447 additions and 5 deletions
org.gradle.jvmargs=-Xmx1536M org.gradle.jvmargs=-Xmx1536M
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=true
app.versionName=0.0.6 app.versionName=0.0.7
app.versionCode=6 app.versionCode=7
...@@ -6,5 +6,7 @@ ...@@ -6,5 +6,7 @@
"statistics_last_scrobble": "Last scrobble: {datetime}", "statistics_last_scrobble": "Last scrobble: {datetime}",
"statistics_selected_period": "On last {daysCount} days:", "statistics_selected_period": "On last {daysCount} days:",
"statistics_recent_scrobbles_count": "Scrobbles: {count}", "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)"
} }
...@@ -6,5 +6,7 @@ ...@@ -6,5 +6,7 @@
"statistics_last_scrobble": "Dernière écoute : {datetime}", "statistics_last_scrobble": "Dernière écoute : {datetime}",
"statistics_selected_period": "Sur les {daysCount} derniers jours:", "statistics_selected_period": "Sur les {daysCount} derniers jours:",
"statistics_recent_scrobbles_count": "Écoutes : {count}", "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)"
} }
Add scrobbles counts main chart.
Ajout du graphique principal de compteur d'écoutes.
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);
}
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);
}
}
...@@ -2,6 +2,7 @@ import 'dart:convert'; ...@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../models/statistics.dart'; import '../models/statistics.dart';
import '../models/timeline.dart';
class ScrobblesApi { class ScrobblesApi {
static String baseUrl = 'https://scrobble.harrault.fr'; static String baseUrl = 'https://scrobble.harrault.fr';
...@@ -15,4 +16,15 @@ class ScrobblesApi { ...@@ -15,4 +16,15 @@ class ScrobblesApi {
throw Exception('Failed to get data from API.'); 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.');
}
}
} }
...@@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; ...@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../widgets/header.dart'; import '../widgets/header.dart';
import '../widgets/main_screen/statistics_card.dart'; import '../widgets/main_screen/statistics_card.dart';
import '../widgets/main_screen/timeline_card.dart';
class MainScreen extends StatelessWidget { class MainScreen extends StatelessWidget {
const MainScreen({super.key}); const MainScreen({super.key});
...@@ -16,6 +17,8 @@ class MainScreen extends StatelessWidget { ...@@ -16,6 +17,8 @@ class MainScreen extends StatelessWidget {
children: <Widget>[ children: <Widget>[
const Header(text: 'app_name'), const Header(text: 'app_name'),
const StatisticsCard(), const StatisticsCard(),
const SizedBox(height: 8),
const ChartTimelineCard(),
const SizedBox(height: 36), const SizedBox(height: 36),
], ],
), ),
......
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,
),
),
);
},
);
}
}
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,
),
);
},
),
);
}
}
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())),
),
],
),
],
);
}
}
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);
}
}
...@@ -57,6 +57,14 @@ packages: ...@@ -57,6 +57,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.0.2" version: "0.0.2"
equatable:
dependency: transitive
description:
name: equatable
sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2
url: "https://pub.dev"
source: hosted
version: "2.0.5"
ffi: ffi:
dependency: transitive dependency: transitive
description: description:
...@@ -73,6 +81,14 @@ packages: ...@@ -73,6 +81,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.0" 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: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
......
...@@ -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.6+6 version: 0.0.7+7
environment: environment:
sdk: '^3.0.0' sdk: '^3.0.0'
...@@ -14,6 +14,7 @@ dependencies: ...@@ -14,6 +14,7 @@ dependencies:
easy_localization: ^3.0.1 easy_localization: ^3.0.1
http: ^1.1.0 http: ^1.1.0
fl_chart: ^0.64.0
flutter: flutter:
uses-material-design: false uses-material-design: false
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment