2025-01-31 10:24:37 +01:00

461 lines
19 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:math';
import 'package:refilc/helpers/average_helper.dart';
import 'package:refilc/models/settings.dart';
import 'package:refilc/theme/colors/colors.dart';
import 'package:refilc_kreta_api/models/grade.dart';
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:intl/intl.dart';
import 'package:i18n_extension/i18n_extension.dart';
import 'package:provider/provider.dart';
import 'graph.i18n.dart';
class GradeGraph extends StatefulWidget {
const GradeGraph(this.data,
{super.key, this.dayThreshold = 7, this.classAvg});
final List<Grade> data;
final int dayThreshold;
final double? classAvg;
@override
GradeGraphState createState() => GradeGraphState();
}
class GradeGraphState extends State<GradeGraph> {
late SettingsProvider settings;
List<Color> getColors(List<Grade> data) {
List<Color> colors = [];
List<List<Grade>> sortedData = [[]];
// Sort by date descending
data.sort((a, b) => -a.writeDate.compareTo(b.writeDate));
// Sort data to points by treshold
for (var element in data) {
if (sortedData.last.isNotEmpty &&
sortedData.last.last.writeDate.difference(element.writeDate).inDays >
widget.dayThreshold) {
sortedData.add([]);
}
for (var dataList in sortedData) {
dataList.add(element);
}
}
// Create FlSpots from points
for (var dataList in sortedData) {
double average = AverageHelper.averageEvals(dataList);
Color clr = average >= 1 && average <= 5
? ColorTween(
begin: settings.gradeColors[average.floor() - 1],
end: settings.gradeColors[average.ceil() - 1])
.transform(average - average.floor())!
: Theme.of(context).colorScheme.secondary;
colors.add(clr);
}
return colors;
}
List<FlSpot> getSpots(List<Grade> data) {
List<FlSpot> subjectData = [];
List<List<Grade>> sortedData = [[]];
// Sort by date descending
data.sort((a, b) => -a.writeDate.compareTo(b.writeDate));
// Sort data to points by treshold
for (var element in data) {
if (sortedData.last.isNotEmpty &&
sortedData.last.last.writeDate.difference(element.writeDate).inDays >
widget.dayThreshold) {
sortedData.add([]);
}
for (var dataList in sortedData) {
dataList.add(element);
}
}
// Create FlSpots from points
for (var dataList in sortedData) {
double average = AverageHelper.averageEvals(dataList);
if (dataList.isNotEmpty) {
subjectData.add(FlSpot(
dataList[0].writeDate.month +
(dataList[0].writeDate.day / 31) +
((dataList[0].writeDate.year - data.last.writeDate.year) * 12),
double.parse(average.toStringAsFixed(2)),
));
}
}
return subjectData;
}
@override
Widget build(BuildContext context) {
settings = Provider.of<SettingsProvider>(context);
List<FlSpot> subjectSpots = [];
List<FlSpot> ghostSpots = [];
List<VerticalLine> extraLinesV = [];
List<HorizontalLine> extraLinesH = [];
// Filter data
List<Grade> data = widget.data
.where((e) => e.value.weight != 0)
.where((e) => e.type == GradeType.midYear)
.where((e) => e.gradeType?.name == "Osztalyzat")
.toList();
// Filter ghost data
List<Grade> ghostData = widget.data
.where((e) => e.value.weight != 0)
.where((e) => e.type == GradeType.ghost)
.toList();
// Calculate average
double average = AverageHelper.averageEvals(data);
// Calculate graph color
Color averageColor = average >= 1 && average <= 5
? ColorTween(
begin: settings.gradeColors[average.floor() - 1],
end: settings.gradeColors[average.ceil() - 1])
.transform(average - average.floor())!
: Theme.of(context).colorScheme.secondary;
// color magic happens here
List<Color> averageColors = getColors(data);
subjectSpots = getSpots(data);
// naplo/#73
if (subjectSpots.isNotEmpty) {
ghostSpots = getSpots(data + ghostData);
// hax
ghostSpots = ghostSpots
.where((e) => e.x >= subjectSpots.map((f) => f.x).reduce(max))
.toList();
ghostSpots = ghostSpots.map((e) => FlSpot(e.x + 0.1, e.y)).toList();
ghostSpots.add(subjectSpots.firstWhere(
(e) => e.x >= subjectSpots.map((f) => f.x).reduce(max),
orElse: () => const FlSpot(-1, -1)));
ghostSpots.removeWhere(
(element) => element.x == -1 && element.y == -1); // naplo/#74
}
Grade halfYearGrade = widget.data.lastWhere(
(e) => e.type == GradeType.halfYear,
orElse: () => Grade.fromJson({}));
if (halfYearGrade.date.year != 0 && data.isNotEmpty) {
final maxX = ghostSpots.isNotEmpty ? ghostSpots.first.x : 0;
final x = halfYearGrade.writeDate.month +
(halfYearGrade.writeDate.day / 31) +
((halfYearGrade.writeDate.year - data.last.writeDate.year) * 12);
List<Grade> dataBeforeMidYr = data
.where((e) => e.writeDate.isBefore(halfYearGrade.writeDate))
.toList();
if (x <= maxX) {
extraLinesV.add(
VerticalLine(
x: x,
strokeWidth: 3.0,
color: AppColors.of(context).red.withValues(alpha: .75),
label: VerticalLineLabel(
labelResolver: (_) => " ${"mid".i18n} ", // <- zwsp for padding
show: true,
alignment: dataBeforeMidYr.length < 2
? Alignment.topRight
: Alignment.topLeft,
style: TextStyle(
backgroundColor: Theme.of(context).colorScheme.surface,
color: AppColors.of(context).text,
fontSize: 16.0,
fontWeight: FontWeight.w600,
),
),
),
);
}
}
// Horizontal line displaying the class average
if (widget.classAvg != null &&
widget.classAvg! > 0.0 &&
settings.graphClassAvg) {
extraLinesH.add(HorizontalLine(
y: widget.classAvg!,
color: AppColors.of(context).text.withValues(alpha: .75),
));
}
// LineChart is really cute because it tries to render it's contents outside of it's rect.
return widget.data.length <= 2
? SizedBox(
height: 150,
child: Center(
child: Text(
"not_enough_grades".i18n,
textAlign: TextAlign.center,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
)
: ClipRect(
child: SizedBox(
height: 158,
child: subjectSpots.length > 1
? Padding(
padding: const EdgeInsets.only(top: 6.0, right: 0.0),
child: LineChart(
LineChartData(
extraLinesData: ExtraLinesData(
verticalLines: extraLinesV,
horizontalLines: extraLinesH),
lineBarsData: [
LineChartBarData(
preventCurveOverShooting: false,
spots: subjectSpots,
isCurved: true,
// colors: averageColors.reversed.toList(),
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: averageColors.reversed.toList(),
),
barWidth: 6,
curveSmoothness: 0.2,
isStrokeCapRound: true,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
averageColor.withValues(alpha: 0.7),
averageColor.withValues(alpha: 0.3),
averageColor.withValues(alpha: 0.2),
averageColor.withValues(alpha: 0.1),
],
stops: const [0.1, 0.6, 0.8, 1],
),
// gradientColorStops: [0.1, 0.6, 0.8, 1],
// gradientFrom: const Offset(0, 0),
// gradientTo: const Offset(0, 1),
),
),
if (ghostData.isNotEmpty && ghostSpots.isNotEmpty)
LineChartBarData(
preventCurveOverShooting: false,
spots: ghostSpots,
isCurved: true,
color: AppColors.of(context).text,
barWidth: 6,
curveSmoothness: 0.2,
isStrokeCapRound: true,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.of(context)
.text
.withValues(alpha: 0.7),
AppColors.of(context)
.text
.withValues(alpha: 0.3),
AppColors.of(context)
.text
.withValues(alpha: 0.2),
AppColors.of(context)
.text
.withValues(alpha: 0.1),
],
stops: const [0.1, 0.6, 0.8, 1],
),
// gradientColorStops: [0.1, 0.6, 0.8, 1],
// gradientFrom: const Offset(0, 0),
// gradientTo: const Offset(0, 1),
),
),
],
minY: 1,
maxY: 5,
gridData: const FlGridData(
show: true,
horizontalInterval: 1,
// checkToShowVerticalLine: (_) => false,
// getDrawingHorizontalLine: (_) => FlLine(
// color: AppColors.of(context).text.withValues(alpha: .15),
// strokeWidth: 2,
// ),
// getDrawingVerticalLine: (_) => FlLine(
// color: AppColors.of(context).text.withValues(alpha: .25),
// strokeWidth: 2,
// ),
),
lineTouchData: LineTouchData(
touchTooltipData: const LineTouchTooltipData(
// tooltipBgColor: Colors.grey.shade800,
fitInsideVertically: true,
fitInsideHorizontally: true,
),
handleBuiltInTouches: true,
touchSpotThreshold: 20.0,
getTouchedSpotIndicator: (_, spots) {
return List.generate(
spots.length,
(index) => TouchedSpotIndicatorData(
FlLine(
color: Colors.grey.shade900,
strokeWidth: 3.5,
),
FlDotData(
getDotPainter: (a, b, c, d) =>
FlDotCirclePainter(
strokeWidth: 0,
color: Colors.grey.shade900,
radius: 10.0,
),
),
),
);
},
),
borderData: FlBorderData(
show: false,
border: Border.all(
color: Theme.of(context).scaffoldBackgroundColor,
width: 4,
),
),
titlesData: FlTitlesData(
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 25,
getTitlesWidget: (value, meta) {
if (value == meta.max || value == meta.min) {
return Container();
}
var format = DateFormat("MMM",
I18n.of(context).locale.toString());
String title = format
.format(DateTime(0, value.floor() % 12))
.replaceAll(".", "");
title =
title.substring(0, min(title.length, 4));
return Text(
title.toUpperCase(),
style: TextStyle(
color: AppColors.of(context)
.text
.withValues(alpha: .75),
fontWeight: FontWeight.bold,
fontSize: 14.0,
),
);
},
// getTextStyles: (context, value) => TextStyle(
// color: AppColors.of(context)
// .text
// .withValues(alpha: .75),
// fontWeight: FontWeight.bold,
// fontSize: 14.0,
// ),
// margin: 12.0,
// getTitles: (value) {
// var format = DateFormat("MMM",
// I18n.of(context).locale.toString());
// String title = format
// .format(DateTime(0, value.floor() % 12))
// .replaceAll(".", "");
// title =
// title.substring(0, min(title.length, 4));
// return title.toUpperCase();
// },
interval: () {
List<Grade> tData =
ghostData.isNotEmpty ? ghostData : data;
tData.sort((a, b) =>
a.writeDate.compareTo(b.writeDate));
return ghostData.isNotEmpty
? 3.0
: tData.first.writeDate
.add(const Duration(days: 120))
.isBefore(tData.last.writeDate)
? 2.0
: 2.5;
}(),
// checkToShowTitle: (double minValue,
// double maxValue,
// SideTitles sideTitles,
// double appliedInterval,
// double value) {
// if (value == maxValue || value == minValue) {
// return false;
// }
// return true;
// },
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
interval: 1.0,
reservedSize: 26.0,
getTitlesWidget: (value, meta) => Padding(
padding: const EdgeInsets.only(
left: 6.0, right: 10.0),
child: Text(
value.toInt().toString(),
style: TextStyle(
color: AppColors.of(context).text,
fontWeight: FontWeight.bold,
fontSize: 16.0,
),
),
),
// getTextStyles: (context, value) => TextStyle(
// color: AppColors.of(context).text,
// fontWeight: FontWeight.bold,
// fontSize: 18.0,
// ),
// margin: 16,
),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
),
),
)
: null,
),
);
}
}