fix: average calculations

This commit is contained in:
checkedear
2026-06-10 14:14:23 +02:00
parent 414a1ad254
commit f0114e3976
4 changed files with 104 additions and 91 deletions

View File

@@ -1,7 +1,9 @@
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
import 'package:firka/api/client/kreta_client.dart';
import 'package:firka_common/ui/components/grade_helpers.dart';
import 'package:kreta_api/kreta_api.dart';
import 'package:firka/core/debug_helper.dart';
import 'package:firka/data/ios_widget_helper.dart';
@@ -249,31 +251,20 @@ class WidgetCacheHelper {
);
final Map<String, double> subjectAverages = {};
final Set<String> subjectUids = {};
final HashSet<Subject> subjects = HashSet(
hashCode: (s) => s.uid.hashCode,
equals: (s, s2) => s.uid == s2.uid,
);
for (var grade in grades) {
subjectUids.add(grade.subject.uid);
}
subjects.addAll(grades.map((g) => g.subject));
double overallSum = 0;
int validSubjectCount = 0;
for (var uid in subjectUids) {
final subjectGrades = grades
.where((g) => g.subject.uid == uid)
.toList();
final avg = _calculateWeightedAverage(subjectGrades);
if (!avg.isNaN && avg > 0) {
subjectAverages[uid] = avg;
overallSum += avg;
validSubjectCount++;
for (var subject in subjects) {
final average = grades.getAverageBySubject(subject);
if (average != null) {
subjectAverages[subject.uid] = average;
}
}
final double? overallAverage = validSubjectCount > 0
? overallSum / validSubjectCount
: null;
WidgetBreakInfo? currentBreak;
await updateIOSWidgets(
@@ -285,7 +276,7 @@ class WidgetCacheHelper {
nextSchoolDayDate: nextSchoolDayDate,
grades: grades,
subjectAverages: subjectAverages,
overallAverage: overallAverage,
overallAverage: grades.getSubjectAverage(),
currentBreak: currentBreak,
);
@@ -315,26 +306,4 @@ class WidgetCacheHelper {
debugPrint('Error clearing iOS widgets: $e');
}
}
/// Calculate weighted average for a list of grades
static double _calculateWeightedAverage(List<Grade> grades) {
var weightTotal = 0.0;
var sum = 0.0;
for (var grade in grades) {
final name = grade.valueType.name?.toLowerCase() ?? '';
final isPercentage =
name.contains('szazalek') || name.contains('percent');
if (isPercentage) continue;
if (grade.numericValue != null) {
var weight = (grade.weightPercentage ?? 100) / 100.0;
weightTotal += weight;
sum += grade.numericValue! * weight;
}
}
if (weightTotal == 0) return double.nan;
return sum / weightTotal;
}
}

View File

@@ -10,7 +10,13 @@ import 'package:flutter/material.dart';
class GradeChart extends StatefulWidget {
final List<Grade> grades;
const GradeChart({super.key, required this.grades});
final bool halfYearFallback;
GradeChart({
super.key,
required List<Grade> grades,
this.halfYearFallback = true,
}) : grades = grades.where((g) => g.hasClassicValue()).toList()
..sort((a, b) => a.recordDate.compareTo(b.recordDate));
@override
State<GradeChart> createState() => _GradeChartState();
@@ -20,6 +26,10 @@ class DateSpot extends FlSpot {
final DateTime date;
const DateSpot(super.x, super.y, this.date);
DateSpot copyWithX(double x) {
return DateSpot(x, y, date);
}
}
class _GradeChartState extends State<GradeChart> {
@@ -36,26 +46,43 @@ class _GradeChartState extends State<GradeChart> {
}
void _computeSpots() {
final sortedGrades =
widget.grades.where((grade) => grade.shouldIncludeInAverage()).toList()
..sort((a, b) => a.recordDate.compareTo(b.recordDate));
if (sortedGrades.isEmpty) {
spots = [DateSpot(0, 0, DateTime.now()), DateSpot(1, 0, DateTime.now())];
return;
}
if (sortedGrades.length == 1) {
sortedGrades.insert(0, sortedGrades[0]);
}
spots = [];
for (var i = 0; i < sortedGrades.length; i++) {
final partialAvg = sortedGrades.take(i + 1).getSubjectAverage();
for (var i = 0; i < widget.grades.length; i++) {
final grade = widget.grades[i];
if (!grade.shouldIncludeInAverage()) {
continue;
}
final partialAvg = widget.grades
.take(i + 1)
.getSubjectAverage(halfYearFallback: widget.halfYearFallback);
spots.add(
DateSpot(i.toDouble(), partialAvg!, sortedGrades[i].recordDate),
DateSpot(spots.length.toDouble(), partialAvg!, grade.recordDate),
);
}
if (spots.isEmpty) {
for (final grade in widget.grades) {
if (grade.hasClassicValue()) {
spots.add(
DateSpot(
spots.length.toDouble(),
grade.numericValue!.toDouble(),
grade.recordDate,
),
);
}
}
}
if (spots.isEmpty) {
spots.add(DateSpot(0, 0, DateTime.now()));
}
if (spots.length == 1) {
spots = [spots[0], spots[0].copyWithX(1)];
}
}
List<FlSpot> _smoothSpots(List<FlSpot> input) {
@@ -356,8 +383,13 @@ class _GradeChartState extends State<GradeChart> {
/// so the navigator does not intercept touch/drag (e.g. for swipe back).
class GradeChartWithInteraction extends StatelessWidget {
final List<Grade> grades;
final bool halfYearFallback;
const GradeChartWithInteraction({super.key, required this.grades});
const GradeChartWithInteraction({
super.key,
required this.grades,
this.halfYearFallback = true,
});
@override
Widget build(BuildContext context) {
@@ -366,7 +398,7 @@ class GradeChartWithInteraction extends StatelessWidget {
onPointerDown: (_) => ChartInteractionScope.of(context).value = true,
onPointerUp: (_) => ChartInteractionScope.of(context).value = false,
onPointerCancel: (_) => ChartInteractionScope.of(context).value = false,
child: GradeChart(grades: grades),
child: GradeChart(grades: grades, halfYearFallback: halfYearFallback),
);
}
}

View File

@@ -43,7 +43,7 @@ class GradeWidget extends StatelessWidget {
);
}
if (g.valueType.name == 'Szazalekos') {
if (g.isInPercentage()) {
final gradeColor = appStyle.colors.accent;
return FilledCircle(
diameter: size,

View File

@@ -58,20 +58,31 @@ extension GradeListExtension on Iterable<Grade> {
return (filtered.length, counts);
}
double? getAverageBySubject(Subject subject) {
double? _getAverageBySubject(String uid, {bool halfYearFallback = true}) {
return where(
(g) => g.subject.uid == subject.uid && g.shouldIncludeInAverage(),
).getAverage();
(g) => g.subject.uid == uid,
).getAverage(halfYearFallback: halfYearFallback);
}
double? getAverageBySubject(Subject subject, {bool halfYearFallback = true}) {
return _getAverageBySubject(
subject.uid,
halfYearFallback: halfYearFallback,
);
}
double? getRoundedSubjectAverage({
bool halfYearFallback = true,
double t1 = 1,
double t2 = 0.5,
double t3 = 0.5,
double t4 = 0.5,
}) {
final averages = map((g) => g.subject).toSet().map((subject) {
final average = getAverageBySubject(subject);
final average = getAverageBySubject(
subject,
halfYearFallback: halfYearFallback,
);
if (average == null) {
return null;
}
@@ -85,10 +96,15 @@ extension GradeListExtension on Iterable<Grade> {
return averages.reduce((sum, avg) => sum + avg) / averages.length;
}
double? getSubjectAverage() {
final averages = map(
(g) => g.subject,
).toSet().map((subject) => getAverageBySubject(subject)).nonNulls;
double? getSubjectAverage({bool halfYearFallback = true}) {
final averages = where((g) => g.hasClassicValue())
.map((g) => g.subject.uid)
.toSet()
.map(
(uid) =>
_getAverageBySubject(uid, halfYearFallback: halfYearFallback),
)
.nonNulls;
if (averages.isEmpty) {
return null;
@@ -97,25 +113,31 @@ extension GradeListExtension on Iterable<Grade> {
return averages.reduce((sum, avg) => sum + avg) / averages.length;
}
double? getAverage() {
double? getAverage({bool halfYearFallback = true}) {
if (isEmpty) {
return null;
}
double weightTotal = 0;
double sum = 0;
int? fallback;
for (Grade grade in this) {
if (grade.hasClassicValue() && halfYearFallback) {
fallback = grade.numericValue!;
}
if (!grade.shouldIncludeInAverage()) {
continue;
}
double weight = (grade.weightPercentage ?? 100) / 100.0;
double weight = grade.weightPercentage! / 100.0;
weightTotal += weight;
sum += grade.numericValue! * weight;
}
if (sum == 0) {
return null;
// use classification exam results or half-year evaluations
return fallback?.toDouble();
}
return sum / weightTotal;
@@ -124,25 +146,15 @@ extension GradeListExtension on Iterable<Grade> {
extension GradeExtension on Grade {
bool isInPercentage() {
final name = valueType.name.toLowerCase();
return name.contains('szazalek') || name.contains('percent');
return valueType.name == 'Szazalekos';
}
bool hasClassicValue() {
return valueType.name == "Osztalyzat";
}
// https://tudasbazis.ekreta.hu/pages/viewpage.action?pageId=2426172
bool shouldIncludeInAverage() {
if (isInPercentage()) {
return false;
}
final typeName = type.name.toLowerCase();
if (typeName == 'felevi_jegy_ertekeles' ||
typeName == 'evvegi_jegy_ertekeles') {
return false;
}
if (numericValue == null) {
return false;
}
return true;
return weightPercentage != null && hasClassicValue();
}
}