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:convert';
import 'dart:io'; import 'dart:io';
import 'package:firka/api/client/kreta_client.dart'; 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:kreta_api/kreta_api.dart';
import 'package:firka/core/debug_helper.dart'; import 'package:firka/core/debug_helper.dart';
import 'package:firka/data/ios_widget_helper.dart'; import 'package:firka/data/ios_widget_helper.dart';
@@ -249,31 +251,20 @@ class WidgetCacheHelper {
); );
final Map<String, double> subjectAverages = {}; 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) { subjects.addAll(grades.map((g) => g.subject));
subjectUids.add(grade.subject.uid);
}
double overallSum = 0; for (var subject in subjects) {
int validSubjectCount = 0; final average = grades.getAverageBySubject(subject);
if (average != null) {
for (var uid in subjectUids) { subjectAverages[subject.uid] = average;
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++;
} }
} }
final double? overallAverage = validSubjectCount > 0
? overallSum / validSubjectCount
: null;
WidgetBreakInfo? currentBreak; WidgetBreakInfo? currentBreak;
await updateIOSWidgets( await updateIOSWidgets(
@@ -285,7 +276,7 @@ class WidgetCacheHelper {
nextSchoolDayDate: nextSchoolDayDate, nextSchoolDayDate: nextSchoolDayDate,
grades: grades, grades: grades,
subjectAverages: subjectAverages, subjectAverages: subjectAverages,
overallAverage: overallAverage, overallAverage: grades.getSubjectAverage(),
currentBreak: currentBreak, currentBreak: currentBreak,
); );
@@ -315,26 +306,4 @@ class WidgetCacheHelper {
debugPrint('Error clearing iOS widgets: $e'); 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 { class GradeChart extends StatefulWidget {
final List<Grade> grades; 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 @override
State<GradeChart> createState() => _GradeChartState(); State<GradeChart> createState() => _GradeChartState();
@@ -20,6 +26,10 @@ class DateSpot extends FlSpot {
final DateTime date; final DateTime date;
const DateSpot(super.x, super.y, this.date); const DateSpot(super.x, super.y, this.date);
DateSpot copyWithX(double x) {
return DateSpot(x, y, date);
}
} }
class _GradeChartState extends State<GradeChart> { class _GradeChartState extends State<GradeChart> {
@@ -36,26 +46,43 @@ class _GradeChartState extends State<GradeChart> {
} }
void _computeSpots() { 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 = []; spots = [];
for (var i = 0; i < sortedGrades.length; i++) { for (var i = 0; i < widget.grades.length; i++) {
final partialAvg = sortedGrades.take(i + 1).getSubjectAverage(); final grade = widget.grades[i];
if (!grade.shouldIncludeInAverage()) {
continue;
}
final partialAvg = widget.grades
.take(i + 1)
.getSubjectAverage(halfYearFallback: widget.halfYearFallback);
spots.add( 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) { 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). /// so the navigator does not intercept touch/drag (e.g. for swipe back).
class GradeChartWithInteraction extends StatelessWidget { class GradeChartWithInteraction extends StatelessWidget {
final List<Grade> grades; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -366,7 +398,7 @@ class GradeChartWithInteraction extends StatelessWidget {
onPointerDown: (_) => ChartInteractionScope.of(context).value = true, onPointerDown: (_) => ChartInteractionScope.of(context).value = true,
onPointerUp: (_) => ChartInteractionScope.of(context).value = false, onPointerUp: (_) => ChartInteractionScope.of(context).value = false,
onPointerCancel: (_) => 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; final gradeColor = appStyle.colors.accent;
return FilledCircle( return FilledCircle(
diameter: size, diameter: size,

View File

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