diff --git a/filcnaplo/lib/app.dart b/filcnaplo/lib/app.dart index 581b4a0..15a410c 100644 --- a/filcnaplo/lib/app.dart +++ b/filcnaplo/lib/app.dart @@ -114,7 +114,8 @@ class App extends StatelessWidget { ChangeNotifierProvider( create: (context) => ExamProvider(context: context)), ChangeNotifierProvider( - create: (context) => HomeworkProvider(context: context)), + create: (context) => + HomeworkProvider(context: context, database: database)), ChangeNotifierProvider( create: (context) => MessageProvider(context: context)), ChangeNotifierProvider( diff --git a/filcnaplo/lib/database/init.dart b/filcnaplo/lib/database/init.dart index 1519f0a..e87209c 100644 --- a/filcnaplo/lib/database/init.dart +++ b/filcnaplo/lib/database/init.dart @@ -50,6 +50,7 @@ const userDataDB = DatabaseStruct("user_data", { "goal_plans": String, "goal_averages": String, "goal_befores": String, + "goal_pin_dates": String, }); Future createTable(Database db, DatabaseStruct struct) => @@ -105,6 +106,7 @@ Future initDB(DatabaseProvider database) async { "goal_plans": "{}", "goal_averages": "{}", "goal_befores": "{}", + "goal_pin_dates": "{}", }); } catch (error) { print("ERROR: migrateDB: $error"); diff --git a/filcnaplo/lib/database/query.dart b/filcnaplo/lib/database/query.dart index b3a6de8..fb3094c 100644 --- a/filcnaplo/lib/database/query.dart +++ b/filcnaplo/lib/database/query.dart @@ -247,4 +247,16 @@ class UserDatabaseQuery { return (jsonDecode(goalBeforesJson) as Map) .map((key, value) => MapEntry(key.toString(), value.toString())); } + + Future> subjectGoalPinDates( + {required String userId}) async { + List userData = + await db.query("user_data", where: "id = ?", whereArgs: [userId]); + if (userData.isEmpty) return {}; + String? goalPinDatesJson = + userData.elementAt(0)["goal_pin_dates"] as String?; + if (goalPinDatesJson == null) return {}; + return (jsonDecode(goalPinDatesJson) as Map) + .map((key, value) => MapEntry(key.toString(), value.toString())); + } } diff --git a/filcnaplo/lib/database/store.dart b/filcnaplo/lib/database/store.dart index 4083bec..185462d 100644 --- a/filcnaplo/lib/database/store.dart +++ b/filcnaplo/lib/database/store.dart @@ -163,4 +163,11 @@ class UserDatabaseStore { await db.update("user_data", {"goal_befores": goalBeforesJson}, where: "id = ?", whereArgs: [userId]); } + + Future storeSubjectGoalPinDates(Map dates, + {required String userId}) async { + String goalPinDatesJson = jsonEncode(dates); + await db.update("user_data", {"goal_pin_dates": goalPinDatesJson}, + where: "id = ?", whereArgs: [userId]); + } } diff --git a/filcnaplo_kreta_api/lib/providers/homework_provider.dart b/filcnaplo_kreta_api/lib/providers/homework_provider.dart index 8f5fa4d..3a20e1f 100644 --- a/filcnaplo_kreta_api/lib/providers/homework_provider.dart +++ b/filcnaplo_kreta_api/lib/providers/homework_provider.dart @@ -22,9 +22,11 @@ class HomeworkProvider with ChangeNotifier { HomeworkProvider({ List initialHomework = const [], required BuildContext context, + required DatabaseProvider database, }) { _homework = List.castFrom(initialHomework); _context = context; + _database = database; if (_homework.isEmpty) restore(); } diff --git a/filcnaplo_mobile_ui/lib/common/progress_bar.dart b/filcnaplo_mobile_ui/lib/common/progress_bar.dart index a89ce68..ffffa00 100755 --- a/filcnaplo_mobile_ui/lib/common/progress_bar.dart +++ b/filcnaplo_mobile_ui/lib/common/progress_bar.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; class ProgressBar extends StatelessWidget { - const ProgressBar({Key? key, required this.value, this.backgroundColor}) : super(key: key); + const ProgressBar( + {Key? key, required this.value, this.backgroundColor, this.height = 8.0}) + : super(key: key); final double value; final Color? backgroundColor; + final double height; @override Widget build(BuildContext context) { @@ -13,11 +16,13 @@ class ProgressBar extends StatelessWidget { // Background Container( decoration: BoxDecoration( - color: Theme.of(context).brightness == Brightness.light ? Colors.black.withOpacity(0.1) : Colors.white.withOpacity(0.1), + color: Theme.of(context).brightness == Brightness.light + ? Colors.black.withOpacity(0.1) + : Colors.white.withOpacity(0.1), borderRadius: BorderRadius.circular(45.0), ), width: double.infinity, - height: 8.0, + height: height, ), // Slider @@ -26,8 +31,9 @@ class ProgressBar extends StatelessWidget { width: double.infinity, child: CustomPaint( painter: ProgressPainter( - backgroundColor: backgroundColor ?? Theme.of(context).colorScheme.secondary, - height: 8.0, + backgroundColor: + backgroundColor ?? Theme.of(context).colorScheme.secondary, + height: height, value: value.clamp(0, 1), ), ), @@ -38,7 +44,10 @@ class ProgressBar extends StatelessWidget { } class ProgressPainter extends CustomPainter { - ProgressPainter({required this.height, required this.value, required this.backgroundColor}); + ProgressPainter( + {required this.height, + required this.value, + required this.backgroundColor}); final double height; final double value; @@ -64,6 +73,8 @@ class ProgressPainter extends CustomPainter { @override bool shouldRepaint(ProgressPainter oldDelegate) { - return value != oldDelegate.value || height != oldDelegate.height || backgroundColor != oldDelegate.backgroundColor; + return value != oldDelegate.value || + height != oldDelegate.height || + backgroundColor != oldDelegate.backgroundColor; } } diff --git a/filcnaplo_premium/lib/ui/mobile/goal_planner/goal_state_screen.dart b/filcnaplo_premium/lib/ui/mobile/goal_planner/goal_state_screen.dart index 0397160..d797fe9 100644 --- a/filcnaplo_premium/lib/ui/mobile/goal_planner/goal_state_screen.dart +++ b/filcnaplo_premium/lib/ui/mobile/goal_planner/goal_state_screen.dart @@ -1,14 +1,21 @@ import 'package:filcnaplo/api/providers/database_provider.dart'; import 'package:filcnaplo/api/providers/user_provider.dart'; +import 'package:filcnaplo/helpers/average_helper.dart'; import 'package:filcnaplo/helpers/subject.dart'; +import 'package:filcnaplo/models/settings.dart'; +import 'package:filcnaplo_kreta_api/models/grade.dart'; import 'package:filcnaplo_kreta_api/models/subject.dart'; -import 'package:filcnaplo_mobile_ui/common/average_display.dart'; +import 'package:filcnaplo_kreta_api/providers/grade_provider.dart'; import 'package:filcnaplo_mobile_ui/common/panel/panel.dart'; +import 'package:filcnaplo_mobile_ui/common/progress_bar.dart'; import 'package:filcnaplo_mobile_ui/common/round_border_icon.dart'; import 'package:filcnaplo_premium/ui/mobile/goal_planner/goal_state_screen.i18n.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; import 'package:provider/provider.dart'; +import 'graph.dart'; + class GoalStateScreen extends StatefulWidget { final Subject subject; @@ -21,11 +28,39 @@ class GoalStateScreen extends StatefulWidget { class _GoalStateScreenState extends State { late UserProvider user; late DatabaseProvider db; + late GradeProvider gradeProvider; + late SettingsProvider settingsProvider; + double currAvg = 0.0; double goalAvg = 0.0; double beforeAvg = 0.0; double avgDifference = 0; + late Widget gradeGraph; + + DateTime goalPinDate = DateTime.now(); + + void fetchGoalAverages() async { + var goalAvgRes = await db.userQuery.subjectGoalAverages(userId: user.id!); + var beforeAvgRes = await db.userQuery.subjectGoalBefores(userId: user.id!); + + goalPinDate = DateTime.parse((await db.userQuery + .subjectGoalPinDates(userId: user.id!))[widget.subject.id]!); + + String? goalAvgStr = goalAvgRes[widget.subject.id]; + String? beforeAvgStr = beforeAvgRes[widget.subject.id]; + goalAvg = double.parse(goalAvgStr ?? '0.0'); + beforeAvg = double.parse(beforeAvgStr ?? '0.0'); + + avgDifference = ((goalAvg - beforeAvg) / beforeAvg.abs()) * 100; + + setState(() {}); + } + + List getSubjectGrades(Subject subject) => gradeProvider.grades + .where((e) => (e.subject == subject && e.date.isAfter(goalPinDate))) + .toList(); + @override void initState() { super.initState(); @@ -37,20 +72,70 @@ class _GoalStateScreenState extends State { }); } - void fetchGoalAverages() async { - var goalAvgRes = await db.userQuery.subjectGoalAverages(userId: user.id!); - var beforeAvgRes = await db.userQuery.subjectGoalBefores(userId: user.id!); - - String? goalAvgStr = goalAvgRes[widget.subject.id]; - String? beforeAvgStr = beforeAvgRes[widget.subject.id]; - goalAvg = double.parse(goalAvgStr ?? '0.0'); - beforeAvg = double.parse(beforeAvgStr ?? '0.0'); - - avgDifference = ((goalAvg - beforeAvg) / beforeAvg.abs()) * 100; - } - @override Widget build(BuildContext context) { + gradeProvider = Provider.of(context); + settingsProvider = Provider.of(context); + + List subjectGrades = getSubjectGrades(widget.subject).toList(); + currAvg = AverageHelper.averageEvals(subjectGrades); + + Color averageColor = currAvg >= 1 && currAvg <= 5 + ? ColorTween( + begin: settingsProvider.gradeColors[currAvg.floor() - 1], + end: settingsProvider.gradeColors[currAvg.ceil() - 1]) + .transform(currAvg - currAvg.floor())! + : Theme.of(context).colorScheme.secondary; + + gradeGraph = Padding( + padding: const EdgeInsets.only( + top: 12.0, + bottom: 8.0, + ), + child: Panel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.only(top: 16.0, right: 12.0), + child: + GoalGraph(subjectGrades, dayThreshold: 5, classAvg: goalAvg), + ), + const SizedBox(height: 5.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'look_at_graph'.i18n, + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 23.0, + ), + ), + Text( + 'thats_progress'.i18n, + style: const TextStyle( + fontWeight: FontWeight.w400, + fontSize: 20.0, + ), + ), + const SizedBox(height: 15.0), + ProgressBar( + value: currAvg / goalAvg, + backgroundColor: averageColor, + height: 16.0, + ), + const SizedBox(height: 8.0), + ], + ), + ), + ], + ), + ), + ); + return Scaffold( body: Container( decoration: const BoxDecoration( @@ -164,6 +249,7 @@ class _GoalStateScreenState extends State { children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'your_goal'.i18n, @@ -191,6 +277,7 @@ class _GoalStateScreenState extends State { ], ), Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( goalAvg.toString(), @@ -200,17 +287,39 @@ class _GoalStateScreenState extends State { fontWeight: FontWeight.w800, ), ), + const SizedBox(width: 10.0), Center( child: Container( - width: 54.0, - padding: - const EdgeInsets.symmetric(vertical: 8.0), + padding: const EdgeInsets.symmetric( + vertical: 5.0, + horizontal: 8.0, + ), decoration: BoxDecoration( borderRadius: BorderRadius.circular(45.0), - color: Colors.limeAccent.shade700 + color: Colors.greenAccent.shade700 .withOpacity(.15), ), - child: Text(avgDifference.toString()), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + FeatherIcons.chevronUp, + color: Colors.greenAccent.shade700, + size: 18.0, + ), + const SizedBox(width: 5.0), + Text( + avgDifference.toStringAsFixed(2) + '%', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.greenAccent.shade700, + fontSize: 22.0, + height: 0.8, + fontWeight: FontWeight.w500, + ), + ), + ], + ), ), ), ], @@ -219,6 +328,11 @@ class _GoalStateScreenState extends State { ), ), ), + const SizedBox(height: 5.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: gradeGraph, + ), ], ), ), diff --git a/filcnaplo_premium/lib/ui/mobile/goal_planner/graph.dart b/filcnaplo_premium/lib/ui/mobile/goal_planner/graph.dart new file mode 100644 index 0000000..724b84a --- /dev/null +++ b/filcnaplo_premium/lib/ui/mobile/goal_planner/graph.dart @@ -0,0 +1,249 @@ +import 'dart:math'; + +import 'package:filcnaplo/helpers/average_helper.dart'; +import 'package:filcnaplo/models/settings.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_kreta_api/models/grade.dart'; +import 'package:filcnaplo_premium/ui/mobile/goal_planner/graph.i18n.dart'; +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:provider/provider.dart'; + +class GoalGraph extends StatefulWidget { + const GoalGraph(this.data, {Key? key, this.dayThreshold = 7, this.classAvg}) + : super(key: key); + + final List data; + final int dayThreshold; + final double? classAvg; + + @override + _GoalGraphState createState() => _GoalGraphState(); +} + +class _GoalGraphState extends State { + late SettingsProvider settings; + + List getSpots(List data) { + List subjectData = []; + List> 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(context); + + List subjectSpots = []; + List ghostSpots = []; + List extraLinesV = []; + List extraLinesH = []; + + // Filter data + List 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 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; + + 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 + } + + // 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.withOpacity(.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( + child: subjectSpots.length > 1 + ? Padding( + padding: const EdgeInsets.only(top: 8.0, right: 8.0), + child: LineChart( + LineChartData( + extraLinesData: ExtraLinesData( + verticalLines: extraLinesV, + horizontalLines: extraLinesH), + lineBarsData: [ + LineChartBarData( + preventCurveOverShooting: true, + spots: subjectSpots, + isCurved: true, + colors: [averageColor], + barWidth: 8, + isStrokeCapRound: true, + dotData: FlDotData(show: false), + belowBarData: BarAreaData( + show: true, + colors: [ + averageColor.withOpacity(0.7), + averageColor.withOpacity(0.3), + averageColor.withOpacity(0.2), + averageColor.withOpacity(0.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: true, + spots: ghostSpots, + isCurved: true, + colors: [AppColors.of(context).text], + barWidth: 8, + isStrokeCapRound: true, + dotData: FlDotData(show: false), + belowBarData: BarAreaData( + show: true, + colors: [ + AppColors.of(context).text.withOpacity(0.7), + AppColors.of(context).text.withOpacity(0.3), + AppColors.of(context).text.withOpacity(0.2), + AppColors.of(context).text.withOpacity(0.1), + ], + gradientColorStops: [0.1, 0.6, 0.8, 1], + gradientFrom: const Offset(0, 0), + gradientTo: const Offset(0, 1), + ), + ), + ], + minY: 1, + maxY: 5, + gridData: FlGridData( + show: true, + horizontalInterval: 1, + // checkToShowVerticalLine: (_) => false, + // getDrawingHorizontalLine: (_) => FlLine( + // color: AppColors.of(context).text.withOpacity(.15), + // strokeWidth: 2, + // ), + // getDrawingVerticalLine: (_) => FlLine( + // color: AppColors.of(context).text.withOpacity(.25), + // strokeWidth: 2, + // ), + ), + lineTouchData: LineTouchData( + touchTooltipData: 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, + ), + ), + ), + ), + ) + : null, + height: 158, + ), + ); + } +} diff --git a/filcnaplo_premium/lib/ui/mobile/goal_planner/graph.i18n.dart b/filcnaplo_premium/lib/ui/mobile/goal_planner/graph.i18n.dart new file mode 100644 index 0000000..50e2ea8 --- /dev/null +++ b/filcnaplo_premium/lib/ui/mobile/goal_planner/graph.i18n.dart @@ -0,0 +1,21 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "not_enough_grades": "Not enough data to show a graph here.", + }, + "hu_hu": { + "not_enough_grades": "Nem szereztél még elég jegyet grafikon mutatáshoz.", + }, + "de_de": { + "not_enough_grades": "Noch nicht genug Noten, um die Grafik zu zeigen.", + }, + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +}