From 1c11338d208a6c8b881c7a28f8593649c37a1b0a Mon Sep 17 00:00:00 2001 From: Kima Date: Tue, 26 Dec 2023 00:04:34 +0100 Subject: [PATCH] added back everything to refilc+ submodule --- README.md | 5 +- analysis_options.yaml | 28 ++ lib/api/auth.dart | 124 +++++ lib/helpers/app_icon_helper.dart | 47 ++ lib/models/premium_result.dart | 21 + lib/models/premium_scopes.dart | 26 + lib/providers/goal_provider.dart | 68 +++ lib/providers/premium_provider.dart | 27 + .../goal_planner/goal_complete_modal.dart | 252 ++++++++++ lib/ui/mobile/goal_planner/goal_input.dart | 180 +++++++ lib/ui/mobile/goal_planner/goal_planner.dart | 191 +++++++ .../goal_planner/goal_planner_screen.dart | 429 ++++++++++++++++ .../goal_planner_screen.i18n.dart | 45 ++ .../goal_planner/goal_state_screen.dart | 469 ++++++++++++++++++ .../goal_planner/goal_state_screen.i18n.dart | 87 ++++ lib/ui/mobile/goal_planner/grade_display.dart | 34 ++ lib/ui/mobile/goal_planner/graph.dart | 249 ++++++++++ lib/ui/mobile/goal_planner/graph.i18n.dart | 21 + lib/ui/mobile/goal_planner/route_option.dart | 202 ++++++++ .../activation_view/activation_dashboard.dart | 197 ++++++++ .../activation_view/activation_view.dart | 72 +++ lib/ui/mobile/premium/premium_inline.dart | 69 +++ lib/ui/mobile/premium/upsell.dart | 194 ++++++++ lib/ui/mobile/settings/app_icon_screen.dart | 240 +++++++++ .../mobile/settings/app_icon_screen.i18n.dart | 36 ++ .../mobile/settings/modify_teacher_names.dart | 436 ++++++++++++++++ lib/ui/mobile/settings/share_theme.dart | 26 + lib/ui/mobile/settings/welcome_message.dart | 146 ++++++ pubspec.yaml | 40 ++ 29 files changed, 3959 insertions(+), 2 deletions(-) create mode 100644 analysis_options.yaml create mode 100644 lib/api/auth.dart create mode 100644 lib/helpers/app_icon_helper.dart create mode 100644 lib/models/premium_result.dart create mode 100644 lib/models/premium_scopes.dart create mode 100644 lib/providers/goal_provider.dart create mode 100644 lib/providers/premium_provider.dart create mode 100644 lib/ui/mobile/goal_planner/goal_complete_modal.dart create mode 100644 lib/ui/mobile/goal_planner/goal_input.dart create mode 100644 lib/ui/mobile/goal_planner/goal_planner.dart create mode 100644 lib/ui/mobile/goal_planner/goal_planner_screen.dart create mode 100644 lib/ui/mobile/goal_planner/goal_planner_screen.i18n.dart create mode 100644 lib/ui/mobile/goal_planner/goal_state_screen.dart create mode 100644 lib/ui/mobile/goal_planner/goal_state_screen.i18n.dart create mode 100644 lib/ui/mobile/goal_planner/grade_display.dart create mode 100644 lib/ui/mobile/goal_planner/graph.dart create mode 100644 lib/ui/mobile/goal_planner/graph.i18n.dart create mode 100644 lib/ui/mobile/goal_planner/route_option.dart create mode 100644 lib/ui/mobile/premium/activation_view/activation_dashboard.dart create mode 100644 lib/ui/mobile/premium/activation_view/activation_view.dart create mode 100644 lib/ui/mobile/premium/premium_inline.dart create mode 100644 lib/ui/mobile/premium/upsell.dart create mode 100644 lib/ui/mobile/settings/app_icon_screen.dart create mode 100644 lib/ui/mobile/settings/app_icon_screen.i18n.dart create mode 100644 lib/ui/mobile/settings/modify_teacher_names.dart create mode 100644 lib/ui/mobile/settings/share_theme.dart create mode 100644 lib/ui/mobile/settings/welcome_message.dart create mode 100644 pubspec.yaml diff --git a/README.md b/README.md index fd7508b..1e41448 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ -# naplo-plus -refilc+ +# reFilc+ ✨ + +A collection of features only accessible for reFilc+ subscribers. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..fd16f92 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at + # https://dart-lang.github.io/linter/lints/index.html. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/lib/api/auth.dart b/lib/api/auth.dart new file mode 100644 index 0000000..e5a7246 --- /dev/null +++ b/lib/api/auth.dart @@ -0,0 +1,124 @@ +import 'dart:async'; +import 'dart:developer'; +import 'dart:io'; + +import 'package:filcnaplo/models/settings.dart'; +import 'package:filcnaplo_premium/models/premium_scopes.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:home_widget/home_widget.dart'; + +class PremiumAuth { + final SettingsProvider _settings; + + PremiumAuth({required SettingsProvider settings}) : _settings = settings; + + initAuth() { + finishAuth("igen"); + // try { + // _sub ??= uriLinkStream.listen( + // (Uri? uri) { + // if (uri != null) { + // final accessToken = uri.queryParameters['access_token']; + // if (accessToken != null) { + // finishAuth(accessToken); + // } + // } + // }, + // onError: (err) { + // log("ERROR: initAuth: $err"); + // }, + // ); + + // launchUrl( + // Uri.parse("https://api.filcnaplo.hu/oauth"), + // mode: LaunchMode.externalApplication, + // ); + // } catch (err, sta) { + // log("ERROR: initAuth: $err\n$sta"); + // } + } + + Future finishAuth(String accessToken) async { + try { + // final res = await http.get(Uri.parse("${FilcAPI.premiumScopesApi}?access_token=${Uri.encodeComponent(accessToken)}")); + // final scopes = ((jsonDecode(res.body) as Map)["scopes"] as List).cast(); + // log("[INFO] Premium auth finish: ${scopes.join(',')}"); + await _settings.update(premiumAccessToken: accessToken); + final result = await refreshAuth(); + if (Platform.isAndroid) updateWidget(); + return result; + } catch (err, sta) { + log("[ERROR] Premium auth failed: $err\n$sta"); + } + + await _settings.update(premiumAccessToken: "", premiumScopes: []); + if (Platform.isAndroid) updateWidget(); + return false; + } + + Future updateWidget() async { + try { + return HomeWidget.updateWidget(name: 'widget_timetable.WidgetTimetable'); + } on PlatformException catch (exception) { + if (kDebugMode) { + print('Error Updating Widget After Auth. $exception'); + } + } + return false; + } + + Future refreshAuth({bool removePremium = false}) async { + await _settings.update( + premiumAccessToken: "igen", + premiumScopes: [PremiumScopes.all], + premiumLogin: "igen", + ); + return true; + //if (!removePremium) { + //if (_settings.premiumAccessToken == "") { + // await _settings.update(premiumScopes: [], premiumLogin: ""); + // return false; + //} + + // Skip premium check when disconnected + // try { + // final status = await InternetAddress.lookup('github.com'); + // if (status.isEmpty) return false; + // } on SocketException catch (_) { + // return false; + // } + + //for (int tries = 0; tries < 3; tries++) { + // try { + // final res = await http.post(Uri.parse(FilcAPI.premiumApi), body: { + // "access_token": _settings.premiumAccessToken, + // }); +// + // if (res.body == "") throw "empty body"; + + // final premium = PremiumResult.fromJson(jsonDecode(res.body) as Map); + // Activation succeeded + // log("[INFO] Premium activated: ${premium.scopes.join(',')}"); + // await _settings.update( + // premiumAccessToken: premium.accessToken, + // premiumScopes: premium.scopes, + // premiumLogin: premium.login, + // ); + // return true; + // } catch (err, sta) { + // log("[ERROR] Premium activation failed: $err\n$sta"); + // } + + // await Future.delayed(const Duration(seconds: 1)); + // + //} + + // Activation failed + //await _settings.update( + // premiumAccessToken: "igen", + // premiumScopes: [PremiumScopes.all], + // premiumLogin: "igen"); + //return false; + } +} diff --git a/lib/helpers/app_icon_helper.dart b/lib/helpers/app_icon_helper.dart new file mode 100644 index 0000000..ee3f4d3 --- /dev/null +++ b/lib/helpers/app_icon_helper.dart @@ -0,0 +1,47 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_dynamic_icon/flutter_dynamic_icon.dart'; +import 'package:android_dynamic_icon/android_dynamic_icon.dart'; + +class AppIconHelper { + static const _channel = MethodChannel('app_icon'); + + static Future setAppIcon(String iconName) async { + if (Platform.isIOS) { + // change icon on ios + try { + if (await FlutterDynamicIcon.supportsAlternateIcons) { + await _channel.invokeMethod('changeIcon', iconName); + // await FlutterDynamicIcon.setApplicationIconBadgeNumber(0); we don't need this rn, but we will + await FlutterDynamicIcon.setAlternateIconName(iconName); + } + } on PlatformException catch (e) { + if (kDebugMode) { + print('Failed to change app icon: ${e.message}'); + } + } catch (e) { + if (kDebugMode) { + print('Ha nem megy, hat nem megy'); + } + } + } else if (Platform.isAndroid) { + // change icon on android + final _androidDynamicIconPlugin = AndroidDynamicIcon(); + await _androidDynamicIconPlugin.changeIcon( + bundleId: 'hu.refilc.naplo', + isNewIcon: iconName != 'refilc_default' ? true : false, + iconName: iconName != 'refilc_default' ? iconName : '', + iconNames: [ + 'refilc_default', + 'refilc_overcomplicated', + 'refilc_concept', + 'refilc_pride', + ], + ); + } else { + // ha nem megy hát nem megy + } + } +} diff --git a/lib/models/premium_result.dart b/lib/models/premium_result.dart new file mode 100644 index 0000000..41383ae --- /dev/null +++ b/lib/models/premium_result.dart @@ -0,0 +1,21 @@ +import 'package:filcnaplo_premium/models/premium_scopes.dart'; + +class PremiumResult { + final String accessToken; + final List scopes; + final String login; + + PremiumResult({ + required this.accessToken, + required this.scopes, + required this.login, + }); + + factory PremiumResult.fromJson(Map json) { + return PremiumResult( + accessToken: json["access_token"] ?? "igen", + scopes: (json["scopes"] ?? [PremiumScopes.all]).cast(), + login: json["login"] ?? "igen", + ); + } +} diff --git a/lib/models/premium_scopes.dart b/lib/models/premium_scopes.dart new file mode 100644 index 0000000..4c48a2e --- /dev/null +++ b/lib/models/premium_scopes.dart @@ -0,0 +1,26 @@ +class PremiumScopes { + /// everything + static const all = "refilc.plus.*"; + + /// idk where it will be but i need it + static const renameTeachers = "refilc.plus.RENAME_TEACHERS"; + static const goalPlanner = "refilc.plus.GOAL_PLANNER"; + static const changeAppIcon = "refilc.plus.CHANGE_APP_ICON"; + + /// tier 1 (Kupak) + + /// custom + + /// tier 2 (Tinta) + + /// tier 3 (Szivacs) + + /// old scopes + static const nickname = "refilc.plus.NICKNAME"; + static const gradeStats = "refilc.plus.GRADE_STATS"; + static const customColors = "refilc.plus.CUSTOM_COLORS"; + static const customIcons = "refilc.plus.CUSTOM_ICONS"; + static const renameSubjects = "refilc.plus.RENAME_SUBJECTS"; + static const timetableWidget = "refilc.plus.TIMETALBE_WIDGET"; + static const fsTimetable = "refilc.plus.FS_TIMETABLE"; +} diff --git a/lib/providers/goal_provider.dart b/lib/providers/goal_provider.dart new file mode 100644 index 0000000..1529903 --- /dev/null +++ b/lib/providers/goal_provider.dart @@ -0,0 +1,68 @@ +import 'package:filcnaplo/api/providers/database_provider.dart'; +import 'package:filcnaplo/api/providers/user_provider.dart'; +import 'package:filcnaplo_kreta_api/models/subject.dart'; +import 'package:filcnaplo_kreta_api/providers/grade_provider.dart'; +import 'package:flutter/widgets.dart'; + +class GoalProvider extends ChangeNotifier { + final DatabaseProvider _db; + final UserProvider _user; + + late bool _done = false; + late GradeSubject? _doneSubject; + + bool get hasDoneGoals => _done; + GradeSubject? get doneSubject => _doneSubject; + + GoalProvider({ + required DatabaseProvider database, + required UserProvider user, + }) : _db = database, + _user = user; + + Future fetchDone({required GradeProvider gradeProvider}) async { + var goalAvgs = await _db.userQuery.subjectGoalAverages(userId: _user.id!); + var beforeAvgs = await _db.userQuery.subjectGoalBefores(userId: _user.id!); + + List subjects = gradeProvider.grades + .map((e) => e.subject) + .toSet() + .toList() + ..sort((a, b) => a.name.compareTo(b.name)); + + goalAvgs.forEach((k, v) { + if (beforeAvgs[k] == v) { + _done = true; + _doneSubject = subjects.where((e) => e.id == k).toList()[0]; + + notifyListeners(); + } + }); + } + + void lock() { + _done = false; + _doneSubject = null; + } + + Future clearGoal(GradeSubject subject) async { + final goalPlans = await _db.userQuery.subjectGoalPlans(userId: _user.id!); + final goalAvgs = await _db.userQuery.subjectGoalAverages(userId: _user.id!); + final goalBeforeGrades = + await _db.userQuery.subjectGoalBefores(userId: _user.id!); + final goalPinDates = + await _db.userQuery.subjectGoalPinDates(userId: _user.id!); + + goalPlans.remove(subject.id); + goalAvgs.remove(subject.id); + goalBeforeGrades.remove(subject.id); + goalPinDates.remove(subject.id); + + await _db.userStore.storeSubjectGoalPlans(goalPlans, userId: _user.id!); + await _db.userStore.storeSubjectGoalAverages(goalAvgs, userId: _user.id!); + await _db.userStore + .storeSubjectGoalBefores(goalBeforeGrades, userId: _user.id!); + await _db.userStore + .storeSubjectGoalPinDates(goalPinDates, userId: _user.id!); + } +} diff --git a/lib/providers/premium_provider.dart b/lib/providers/premium_provider.dart new file mode 100644 index 0000000..9809fb4 --- /dev/null +++ b/lib/providers/premium_provider.dart @@ -0,0 +1,27 @@ +import 'package:filcnaplo/models/settings.dart'; +import 'package:filcnaplo_premium/api/auth.dart'; +import 'package:flutter/widgets.dart'; + +class PremiumProvider extends ChangeNotifier { + final SettingsProvider _settings; + List get scopes => _settings.premiumScopes; + bool hasScope(String scope) => true; + String get accessToken => _settings.premiumAccessToken; + String get login => _settings.premiumLogin; + bool get hasPremium => true; + + late final PremiumAuth _auth; + PremiumAuth get auth => _auth; + + PremiumProvider({required SettingsProvider settings}) : _settings = settings { + _auth = PremiumAuth(settings: _settings); + _settings.addListener(() { + notifyListeners(); + }); + } + + Future activate({bool removePremium = false}) async { + await _auth.refreshAuth(removePremium: removePremium); + notifyListeners(); + } +} diff --git a/lib/ui/mobile/goal_planner/goal_complete_modal.dart b/lib/ui/mobile/goal_planner/goal_complete_modal.dart new file mode 100644 index 0000000..cce1654 --- /dev/null +++ b/lib/ui/mobile/goal_planner/goal_complete_modal.dart @@ -0,0 +1,252 @@ +import 'package:filcnaplo/api/providers/database_provider.dart'; +import 'package:filcnaplo/api/providers/user_provider.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_kreta_api/models/subject.dart'; +import 'package:filcnaplo_mobile_ui/common/average_display.dart'; +import 'package:filcnaplo_premium/ui/mobile/goal_planner/goal_state_screen.i18n.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class GoalCompleteModal extends StatelessWidget { + const GoalCompleteModal( + this.subject, { + Key? key, + required this.user, + required this.database, + required this.goalAverage, + required this.beforeAverage, + required this.averageDifference, + }) : super(key: key); + + final UserProvider user; + final DatabaseProvider database; + final GradeSubject subject; + + final double goalAverage; + final double beforeAverage; + final double averageDifference; + + @override + Widget build(BuildContext context) { + return Dialog( + elevation: 0, + backgroundColor: Colors.transparent, + child: Container( + padding: const EdgeInsets.all(16.0), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.background, + borderRadius: BorderRadius.circular(20.0), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: double.infinity, + decoration: BoxDecoration( + image: const DecorationImage( + image: AssetImage('assets/images/static_confetti.png'), + fit: BoxFit.fitWidth, + alignment: Alignment.topCenter, + ), + color: Colors.white, + borderRadius: BorderRadius.circular(10.0), + ), + padding: const EdgeInsets.all(6.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + goalAverage.toStringAsFixed(1), + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 64.0, + fontWeight: FontWeight.w800, + ), + ), + // const SizedBox(width: 10.0), + // Icon( + // SubjectIcon.resolveVariant( + // subject: subject, context: context), + // color: Colors.white, + // size: 64.0, + // ), + ], + ), + ), + const SizedBox(height: 10.0), + Text( + 'congrats_title'.i18n, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 27.0, + fontWeight: FontWeight.w700, + height: 1.2, + color: AppColors.of(context).text, + ), + ), + Text( + 'goal_reached'.i18n.fill(['20']), + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 15.0, + fontWeight: FontWeight.w500, + height: 1.1, + color: AppColors.of(context).text, + ), + ), + const SizedBox(height: 18.0), + Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'started_at'.i18n, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 17.0, + fontWeight: FontWeight.w500, + color: AppColors.of(context).text, + ), + ), + const SizedBox(width: 5.0), + AverageDisplay( + average: beforeAverage, + ), + ], + ), + Text( + 'improved_by'.i18n.fill([ + averageDifference.toStringAsFixed(2) + '%', + ]), + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 17.0, + fontWeight: FontWeight.w500, + color: AppColors.of(context).text, + ), + ), + ], + ), + const SizedBox(height: 20.0), + Column( + children: [ + GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Hamarosan...")), + ); + }, + child: Container( + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10.0), + gradient: const LinearGradient( + colors: [ + Color(0xFFCAECFA), + Color(0xFFF4D9EE), + Color(0xFFF3EFDA), + ], + stops: [0.0, 0.53, 1.0], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Text( + 'detailed_stats'.i18n, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 18.0, + fontWeight: FontWeight.w700, + color: Color(0xFF691A9B), + ), + ), + ), + ), + ), + const SizedBox(height: 10.0), + GestureDetector( + onTap: () { + Navigator.of(context).pop(); + }, + child: Container( + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10.0), + color: const Color.fromARGB(38, 131, 131, 131), + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Text( + 'later'.i18n, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 18.0, + fontWeight: FontWeight.w700, + color: AppColors.of(context).text, + ), + ), + ), + ), + ), + ], + ), + ], + ), + ), + ); + + // return Padding( + // padding: const EdgeInsets.symmetric(vertical: 100.0, horizontal: 32.0), + // child: Material( + // borderRadius: BorderRadius.circular(12.0), + // child: Padding( + // padding: const EdgeInsets.all(12.0), + // child: Column( + // children: [ + // // content or idk + // ], + // ), + // ), + // ), + // ); + } + + static Future show( + GradeSubject subject, { + required BuildContext context, + }) async { + UserProvider user = Provider.of(context, listen: false); + DatabaseProvider db = Provider.of(context, listen: false); + + var goalAvgRes = await db.userQuery.subjectGoalAverages(userId: user.id!); + var beforeAvgRes = await db.userQuery.subjectGoalBefores(userId: user.id!); + + //DateTime goalPinDate = DateTime.parse((await db.userQuery.subjectGoalPinDates(userId: user.id!))[widget.subject.id]!); + + String? goalAvgStr = goalAvgRes[subject.id]; + String? beforeAvgStr = beforeAvgRes[subject.id]; + double goalAvg = double.parse(goalAvgStr ?? '0.0'); + double beforeAvg = double.parse(beforeAvgStr ?? '0.0'); + + double avgDifference = ((goalAvg - beforeAvg) / beforeAvg.abs()) * 100; + + return showDialog( + context: context, + builder: (context) => GoalCompleteModal( + subject, + user: user, + database: db, + goalAverage: goalAvg, + beforeAverage: beforeAvg, + averageDifference: avgDifference, + ), + barrierDismissible: false, + ); + } +} diff --git a/lib/ui/mobile/goal_planner/goal_input.dart b/lib/ui/mobile/goal_planner/goal_input.dart new file mode 100644 index 0000000..e278c16 --- /dev/null +++ b/lib/ui/mobile/goal_planner/goal_input.dart @@ -0,0 +1,180 @@ +import 'package:filcnaplo/models/settings.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +class GoalInput extends StatelessWidget { + const GoalInput( + {Key? key, + required this.currentAverage, + required this.value, + required this.onChanged}) + : super(key: key); + + final double currentAverage; + final double value; + final void Function(double value) onChanged; + + void offsetToValue(Offset offset, Size size) { + double v = ((offset.dx / size.width * 4 + 1) * 10).round() / 10; + v = v.clamp(1.5, 5); + v = v.clamp(((currentAverage * 10).round() / 10), 5); + setValue(v); + } + + void setValue(double v) { + if (v != value) { + HapticFeedback.lightImpact(); + } + onChanged(v); + } + + @override + Widget build(BuildContext context) { + SettingsProvider settings = Provider.of(context); + + List presets = [2, 3, 4, 5]; + presets = presets.where((e) => gradeToAvg(e) > currentAverage).toList(); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + LayoutBuilder(builder: (context, size) { + return GestureDetector( + onTapDown: (details) { + offsetToValue(details.localPosition, size.biggest); + }, + onHorizontalDragUpdate: (details) { + offsetToValue(details.localPosition, size.biggest); + }, + child: SizedBox( + height: 32.0, + width: double.infinity, + child: Padding( + padding: const EdgeInsets.only(right: 20.0), + child: CustomPaint( + painter: GoalSliderPainter( + value: (value - 1) / 4, settings: settings), + ), + ), + ), + ); + }), + const SizedBox(height: 12.0), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: presets.map((e) { + final pv = (value * 10).round() / 10; + final selected = gradeToAvg(e) == pv; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(99.0), + color: + gradeColor(e, settings).withOpacity(selected ? 1.0 : 0.2), + border: Border.all(color: gradeColor(e, settings), width: 4), + ), + child: Material( + type: MaterialType.transparency, + child: InkWell( + borderRadius: BorderRadius.circular(99.0), + onTap: () => setValue(gradeToAvg(e)), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 2.0, horizontal: 24.0), + child: Text( + e.toString(), + style: TextStyle( + color: + selected ? Colors.white : gradeColor(e, settings), + fontWeight: FontWeight.bold, + fontSize: 24.0, + ), + ), + ), + ), + ), + ), + ); + }).toList(), + ) + ], + ); + } +} + +class GoalSliderPainter extends CustomPainter { + final double value; + final SettingsProvider settings; + + GoalSliderPainter({required this.value, required this.settings}); + + @override + void paint(Canvas canvas, Size size) { + final radius = size.height / 2; + const cpadding = 4; + final rect = Rect.fromLTWH(0, 0, size.width + radius, size.height); + final vrect = Rect.fromLTWH(0, 0, size.width * value + radius, size.height); + canvas.drawRRect( + RRect.fromRectAndRadius( + rect, + const Radius.circular(99.0), + ), + Paint()..color = Colors.black.withOpacity(.1), + ); + canvas.drawRRect( + RRect.fromRectAndRadius( + vrect, + const Radius.circular(99.0), + ), + Paint() + ..shader = LinearGradient(colors: [ + settings.gradeColors[0], + settings.gradeColors[1], + settings.gradeColors[2], + settings.gradeColors[3], + settings.gradeColors[4], + ]).createShader(rect), + ); + canvas.drawOval( + Rect.fromCircle( + center: Offset(size.width * value, size.height / 2), + radius: radius - cpadding), + Paint()..color = Colors.white, + ); + for (int i = 1; i < 4; i++) { + canvas.drawOval( + Rect.fromCircle( + center: Offset(size.width / 4 * i, size.height / 2), radius: 4), + Paint()..color = Colors.white.withOpacity(.5), + ); + } + } + + @override + bool shouldRepaint(GoalSliderPainter oldDelegate) { + return oldDelegate.value != value; + } +} + +double gradeToAvg(int grade) { + return grade - 0.5; +} + +Color gradeColor(int grade, SettingsProvider settings) { + // return [ + // const Color(0xffFF3B30), + // const Color(0xffFF9F0A), + // const Color(0xffFFD60A), + // const Color(0xff34C759), + // const Color(0xff247665), + // ].elementAt(grade.clamp(1, 5) - 1); + return [ + settings.gradeColors[0], + settings.gradeColors[1], + settings.gradeColors[2], + settings.gradeColors[3], + settings.gradeColors[4], + ].elementAt(grade.clamp(1, 5) - 1); +} diff --git a/lib/ui/mobile/goal_planner/goal_planner.dart b/lib/ui/mobile/goal_planner/goal_planner.dart new file mode 100644 index 0000000..b1587ee --- /dev/null +++ b/lib/ui/mobile/goal_planner/goal_planner.dart @@ -0,0 +1,191 @@ +/* + * Maintainer: DarK + * Translated from C version + * Minimal Working Fixed @ 2022.12.25 + * ##Please do NOT modify if you don't know whats going on## + * + * Issue: #59 + * + * Future changes / ideas: + * - `best` should be configurable + */ +import 'dart:math'; +import 'package:filcnaplo_kreta_api/models/category.dart'; +import 'package:filcnaplo_kreta_api/models/grade.dart'; +import 'package:filcnaplo_kreta_api/models/subject.dart'; +import 'package:filcnaplo_kreta_api/models/teacher.dart'; +import 'package:flutter/foundation.dart' show listEquals; + +/// Generate list of grades that achieve the wanted goal. +/// After generating possible options, it (when doing so would NOT result in empty list) filters with two criteria: +/// - Plan should not contain more than 15 grades +/// - Plan should not contain only one type of grade +/// +/// **Usage**: +/// +/// ```dart +/// List GoalPlanner(double goal, List grades).solve().plan +/// ``` +class GoalPlanner { + final double goal; + final List grades; + List plans = []; + GoalPlanner(this.goal, this.grades); + + bool _allowed(int grade) => grade > goal; + + void _generate(Generator g) { + // Exit condition 1: Generator has working plan. + if (g.currentAvg.avg >= goal) { + plans.add(Plan(g.plan)); + return; + } + // Exit condition 2: Generator plan will never work. + if (!_allowed(g.gradeToAdd)) { + return; + } + + for (int i = g.max; i >= 0; i--) { + int newGradeToAdd = g.gradeToAdd - 1; + List newPlan = + GoalPlannerHelper._addToList(g.plan, g.gradeToAdd, i); + + Avg newAvg = GoalPlannerHelper._addToAvg(g.currentAvg, g.gradeToAdd, i); + int newN = GoalPlannerHelper.howManyNeeded( + newGradeToAdd, + grades + + newPlan + .map((e) => Grade( + id: '', + date: DateTime(0), + value: GradeValue(e, '', '', 100), + teacher: Teacher.fromString(''), + description: '', + form: '', + groupId: '', + type: GradeType.midYear, + subject: GradeSubject.fromJson({}), + mode: Category.fromJson({}), + seenDate: DateTime(0), + writeDate: DateTime(0), + )) + .toList(), + goal); + + _generate(Generator(newGradeToAdd, newN, newAvg, newPlan)); + } + } + + List solve() { + _generate( + Generator( + 5, + GoalPlannerHelper.howManyNeeded( + 5, + grades, + goal, + ), + Avg(GoalPlannerHelper.averageEvals(grades), + GoalPlannerHelper.weightSum(grades)), + [], + ), + ); + + // Calculate Statistics + for (var e in plans) { + e.sum = e.plan.fold(0, (int a, b) => a + b); + e.avg = e.sum / e.plan.length; + e.sigma = sqrt( + e.plan.map((i) => pow(i - e.avg, 2)).fold(0, (num a, b) => a + b) / + e.plan.length); + } + + // filter without aggression + if (plans.where((e) => e.plan.length < 30).isNotEmpty) { + plans.removeWhere((e) => !(e.plan.length < 30)); + } + if (plans.where((e) => e.sigma > 1).isNotEmpty) { + plans.removeWhere((e) => !(e.sigma > 1)); + } + + return plans; + } +} + +class Avg { + final double avg; + final double n; + + Avg(this.avg, this.n); +} + +class Generator { + final int gradeToAdd; + final int max; + final Avg currentAvg; + final List plan; + + Generator(this.gradeToAdd, this.max, this.currentAvg, this.plan); +} + +class Plan { + final List plan; + int sum = 0; + double avg = 0; + int med = 0; // currently + int mod = 0; // unused + double sigma = 0; + + Plan(this.plan); + + String get dbString { + var finalString = ''; + for (var i in plan) { + finalString += "$i,"; + } + return finalString; + } + + @override + bool operator ==(other) => other is Plan && listEquals(plan, other.plan); + + @override + int get hashCode => Object.hashAll(plan); +} + +class GoalPlannerHelper { + static Avg _addToAvg(Avg base, int grade, int n) => + Avg((base.avg * base.n + grade * n) / (base.n + n), base.n + n); + + static List _addToList(List l, T e, int n) { + if (n == 0) return l; + List tmp = l; + for (int i = 0; i < n; i++) { + tmp = tmp + [e]; + } + return tmp; + } + + static int howManyNeeded(int grade, List base, double goal) { + double avg = averageEvals(base); + double wsum = weightSum(base); + if (avg >= goal) return 0; + if (grade * 1.0 == goal) return -1; + int candidate = (wsum * (avg - goal) / (goal - grade)).floor(); + return (candidate * grade + avg * wsum) / (candidate + wsum) < goal + ? candidate + 1 + : candidate; + } + + static double averageEvals(List grades, {bool finalAvg = false}) { + double average = grades + .map((e) => e.value.value * e.value.weight / 100.0) + .fold(0.0, (double a, double b) => a + b) / + weightSum(grades, finalAvg: finalAvg); + return average.isNaN ? 0.0 : average; + } + + static double weightSum(List grades, {bool finalAvg = false}) => grades + .map((e) => finalAvg ? 1 : e.value.weight / 100) + .fold(0, (a, b) => a + b); +} diff --git a/lib/ui/mobile/goal_planner/goal_planner_screen.dart b/lib/ui/mobile/goal_planner/goal_planner_screen.dart new file mode 100644 index 0000000..258a0f1 --- /dev/null +++ b/lib/ui/mobile/goal_planner/goal_planner_screen.dart @@ -0,0 +1,429 @@ +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/group_average.dart'; +import 'package:filcnaplo_kreta_api/models/subject.dart'; +import 'package:filcnaplo_kreta_api/providers/grade_provider.dart'; +import 'package:filcnaplo_mobile_ui/common/average_display.dart'; +import 'package:filcnaplo_mobile_ui/common/round_border_icon.dart'; +import 'package:filcnaplo_mobile_ui/pages/grades/calculator/grade_calculator_provider.dart'; +import 'package:filcnaplo_premium/ui/mobile/goal_planner/goal_input.dart'; +import 'package:filcnaplo_premium/ui/mobile/goal_planner/goal_planner.dart'; +import 'package:filcnaplo_premium/ui/mobile/goal_planner/goal_planner_screen.i18n.dart'; +import 'package:filcnaplo_premium/ui/mobile/goal_planner/route_option.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +enum PlanResult { + available, // There are possible solutions + unreachable, // The solutions are too hard don't even try + unsolvable, // There are no solutions + reached, // Goal already reached +} + +class GoalPlannerScreen extends StatefulWidget { + final GradeSubject subject; + + const GoalPlannerScreen({Key? key, required this.subject}) : super(key: key); + + @override + State createState() => _GoalPlannerScreenState(); +} + +class _GoalPlannerScreenState extends State { + late GradeProvider gradeProvider; + late GradeCalculatorProvider calculatorProvider; + late SettingsProvider settingsProvider; + late DatabaseProvider dbProvider; + late UserProvider user; + + bool gradeCalcMode = false; + + List getSubjectGrades(GradeSubject subject) => !gradeCalcMode + ? gradeProvider.grades.where((e) => e.subject == subject).toList() + : calculatorProvider.grades.where((e) => e.subject == subject).toList(); + + double goalValue = 4.0; + List grades = []; + + Plan? recommended; + Plan? fastest; + Plan? selectedRoute; + List otherPlans = []; + + @override + void initState() { + super.initState(); + user = Provider.of(context, listen: false); + dbProvider = Provider.of(context, listen: false); + } + + Future> fetchGoalPlans() async { + return await dbProvider.userQuery.subjectGoalPlans(userId: user.id!); + } + + Future> fetchGoalAverages() async { + return await dbProvider.userQuery.subjectGoalAverages(userId: user.id!); + } + + // haha bees lol + Future> fetchGoalBees() async { + return await dbProvider.userQuery.subjectGoalBefores(userId: user.id!); + } + + Future> fetchGoalPinDates() async { + return await dbProvider.userQuery.subjectGoalPinDates(userId: user.id!); + } + + PlanResult getResult() { + final currentAvg = GoalPlannerHelper.averageEvals(grades); + + recommended = null; + fastest = null; + otherPlans = []; + + if (currentAvg >= goalValue) return PlanResult.reached; + + final planner = GoalPlanner(goalValue, grades); + final plans = planner.solve(); + + plans.sort((a, b) => (a.avg - (2 * goalValue + 5) / 3) + .abs() + .compareTo(b.avg - (2 * goalValue + 5) / 3)); + + try { + final singleSolution = plans.every((e) => e.sigma == 0); + recommended = + plans.where((e) => singleSolution ? true : e.sigma > 0).first; + plans.removeWhere((e) => e == recommended); + } catch (_) {} + + plans.sort((a, b) => a.plan.length.compareTo(b.plan.length)); + + try { + fastest = plans.removeAt(0); + } catch (_) {} + + if ((recommended?.plan.length ?? 0) - (fastest?.plan.length ?? 0) >= 3) { + recommended = fastest; + } + + if (recommended == null) { + recommended = null; + fastest = null; + otherPlans = []; + selectedRoute = null; + return PlanResult.unsolvable; + } + + if (recommended!.plan.length > 10) { + recommended = null; + fastest = null; + otherPlans = []; + selectedRoute = null; + return PlanResult.unreachable; + } + + otherPlans = List.from(plans); + + return PlanResult.available; + } + + void getGrades() { + grades = getSubjectGrades(widget.subject).toList(); + } + + @override + Widget build(BuildContext context) { + gradeProvider = Provider.of(context); + calculatorProvider = Provider.of(context); + settingsProvider = Provider.of(context); + + getGrades(); + + final currentAvg = GoalPlannerHelper.averageEvals(grades); + + final result = getResult(); + + List subjectGrades = getSubjectGrades(widget.subject); + + double avg = AverageHelper.averageEvals(subjectGrades); + + var nullavg = GroupAverage(average: 0.0, subject: widget.subject, uid: "0"); + double groupAverage = gradeProvider.groupAverages + .firstWhere((e) => e.subject == widget.subject, orElse: () => nullavg) + .average; + + return Scaffold( + body: SafeArea( + child: ListView( + padding: const EdgeInsets.only( + top: 5.0, + bottom: 220.0, + right: 15.0, + left: 2.0, + ), + children: [ + // Row( + // mainAxisAlignment: MainAxisAlignment.spaceBetween, + // children: [ + // const BackButton(), + // Padding( + // padding: const EdgeInsets.only(right: 15.0), + // child: Row( + // children: [ + // Text( + // 'goal_planner_title'.i18n, + // style: const TextStyle( + // fontWeight: FontWeight.w500, fontSize: 18.0), + // ), + // const SizedBox( + // width: 5, + // ), + // const BetaChip(), + // ], + // ), + // ), + // ], + // ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( + children: [ + const BackButton(), + RoundBorderIcon( + icon: Icon( + SubjectIcon.resolveVariant( + context: context, + subject: widget.subject, + ), + size: 18, + weight: 1.5, + ), + ), + const SizedBox( + width: 5.0, + ), + Text( + (widget.subject.isRenamed + ? widget.subject.renamedTo + : widget.subject.name) ?? + 'goal_planner_title'.i18n, + style: const TextStyle( + fontSize: 20.0, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + Row( + children: [ + if (groupAverage != 0) + AverageDisplay(average: groupAverage, border: true), + const SizedBox(width: 6.0), + AverageDisplay(average: avg), + ], + ), + ], + ), + const SizedBox(height: 12.0), + Padding( + padding: const EdgeInsets.only(left: 22.0, right: 22.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "set_a_goal".i18n, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 20.0, + ), + ), + const SizedBox(height: 4.0), + Text( + goalValue.toString(), + style: TextStyle( + fontWeight: FontWeight.w900, + fontSize: 48.0, + color: gradeColor(goalValue.round(), settingsProvider), + ), + ), + // Column( + // mainAxisAlignment: MainAxisAlignment.center, + // children: [ + // Text( + // "select_subject".i18n, + // style: const TextStyle( + // fontWeight: FontWeight.bold, + // fontSize: 20.0, + // ), + // ), + // const SizedBox(height: 4.0), + // Column( + // children: [ + // Icon( + // SubjectIcon.resolveVariant( + // context: context, + // subject: widget.subject, + // ), + // size: 48.0, + // ), + // Text( + // (widget.subject.isRenamed + // ? widget.subject.renamedTo + // : widget.subject.name) ?? + // '', + // style: const TextStyle( + // fontSize: 17.0, + // fontWeight: FontWeight.w500, + // ), + // ) + // ], + // ) + // ], + // ) + const SizedBox(height: 24.0), + Text( + "pick_route".i18n, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 20.0, + ), + ), + const SizedBox(height: 12.0), + if (recommended != null) + RouteOption( + plan: recommended!, + mark: RouteMark.recommended, + selected: selectedRoute == recommended!, + onSelected: () => setState(() { + selectedRoute = recommended; + }), + ), + if (fastest != null && fastest != recommended) + RouteOption( + plan: fastest!, + mark: RouteMark.fastest, + selected: selectedRoute == fastest!, + onSelected: () => setState(() { + selectedRoute = fastest; + }), + ), + ...otherPlans.map((e) => RouteOption( + plan: e, + selected: selectedRoute == e, + onSelected: () => setState(() { + selectedRoute = e; + }), + )), + if (result != PlanResult.available) Text(result.name.i18n), + ], + ), + ), + ], + ), + ), + bottomSheet: MediaQuery.removePadding( + context: context, + removeBottom: false, + removeTop: true, + child: Container( + color: Theme.of(context).scaffoldBackgroundColor, + child: Container( + padding: const EdgeInsets.only(top: 24.0), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.background, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(24.0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(.1), + blurRadius: 8.0, + ) + ]), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + GoalInput( + value: goalValue, + currentAverage: currentAvg, + onChanged: (v) => setState(() { + selectedRoute = null; + goalValue = v; + }), + ), + const SizedBox(height: 24.0), + SizedBox( + width: double.infinity, + child: RawMaterialButton( + onPressed: () async { + if (selectedRoute == null) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('${"pick_route".i18n}...'))); + } + + final goalPlans = await fetchGoalPlans(); + final goalAvgs = await fetchGoalAverages(); + final goalBeforeGrades = await fetchGoalBees(); + final goalPinDates = await fetchGoalPinDates(); + + goalPlans[widget.subject.id] = + selectedRoute!.dbString; + goalAvgs[widget.subject.id] = + goalValue.toStringAsFixed(2); + goalBeforeGrades[widget.subject.id] = + avg.toStringAsFixed(2); + goalPinDates[widget.subject.id] = + DateTime.now().toIso8601String(); + // goalPlans[widget.subject.id] = '1,2,3,4,5,'; + // goalAvgs[widget.subject.id] = '3.69'; + // goalBeforeGrades[widget.subject.id] = '3.69'; + // goalPinDates[widget.subject.id] = + // DateTime.now().toIso8601String(); + + await dbProvider.userStore.storeSubjectGoalPlans( + goalPlans, + userId: user.id!); + await dbProvider.userStore.storeSubjectGoalAverages( + goalAvgs, + userId: user.id!); + await dbProvider.userStore.storeSubjectGoalBefores( + goalBeforeGrades, + userId: user.id!); + await dbProvider.userStore.storeSubjectGoalPinDates( + goalPinDates, + userId: user.id!); + + Navigator.of(context).pop(); + }, + fillColor: Theme.of(context).colorScheme.primary, + shape: const StadiumBorder(), + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Text( + "track_it".i18n, + style: const TextStyle( + color: Colors.white, + fontSize: 20.0, + fontWeight: FontWeight.w600, + ), + ), + ), + ) + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/ui/mobile/goal_planner/goal_planner_screen.i18n.dart b/lib/ui/mobile/goal_planner/goal_planner_screen.i18n.dart new file mode 100644 index 0000000..0e99214 --- /dev/null +++ b/lib/ui/mobile/goal_planner/goal_planner_screen.i18n.dart @@ -0,0 +1,45 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "goal_planner_title": "Goal Planning", + "set_a_goal": "Your Goal", + "select_subject": "Subject", + "pick_route": "Pick a Route", + "track_it": "Track it!", + "recommended": "Recommended", + "fastest": "Fastest", + "unsolvable": "Unsolvable :(", + "unreachable": "Unreachable :(", + }, + "hu_hu": { + "goal_planner_title": "Cél követés", + "set_a_goal": "Kitűzött cél", + "select_subject": "Tantárgy", + "pick_route": "Válassz egy utat", + "track_it": "Követés!", + "recommended": "Ajánlott", + "fastest": "Leggyorsabb", + "unsolvable": "Megoldhatatlan :(", + "unreachable": "Elérhetetlen :(", + }, + "de_de": { + "goal_planner_title": "Zielplanung", + "set_a_goal": "Dein Ziel", + "select_subject": "Thema", + "pick_route": "Wähle einen Weg", + "track_it": "Verfolge es!", + "recommended": "Empfohlen", + "fastest": "Am schnellsten", + "unsolvable": "Unlösbar :(", + "unreachable": "Unerreichbar :(", + }, + }; + + 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); +} diff --git a/lib/ui/mobile/goal_planner/goal_state_screen.dart b/lib/ui/mobile/goal_planner/goal_state_screen.dart new file mode 100644 index 0000000..ba577c8 --- /dev/null +++ b/lib/ui/mobile/goal_planner/goal_state_screen.dart @@ -0,0 +1,469 @@ +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_kreta_api/providers/grade_provider.dart'; +import 'package:filcnaplo_mobile_ui/common/action_button.dart'; +import 'package:filcnaplo_mobile_ui/common/average_display.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/providers/goal_provider.dart'; +import 'package:filcnaplo_premium/ui/mobile/goal_planner/goal_planner.dart'; +import 'package:filcnaplo_premium/ui/mobile/goal_planner/goal_state_screen.i18n.dart'; +import 'package:filcnaplo_premium/ui/mobile/goal_planner/route_option.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'package:provider/provider.dart'; + +import 'goal_planner_screen.dart'; +import 'graph.dart'; + +class GoalStateScreen extends StatefulWidget { + final GradeSubject subject; + + const GoalStateScreen({Key? key, required this.subject}) : super(key: key); + + @override + State createState() => _GoalStateScreenState(); +} + +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 afterAvg = 0.0; + double avgDifference = 0; + + Plan? plan; + + 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(() {}); + } + + void fetchGoalPlan() async { + var planRes = await db.userQuery.subjectGoalPlans(userId: user.id!); + List prePlan = planRes[widget.subject.id]!.split(','); + prePlan.removeLast(); + + plan = Plan( + prePlan.map((e) => int.parse(e)).toList(), + ); + + setState(() {}); + } + + List getSubjectGrades(GradeSubject subject) => + gradeProvider.grades.where((e) => (e.subject == subject)).toList(); + + List getAfterGoalGrades(GradeSubject subject) => gradeProvider.grades + .where((e) => (e.subject == subject && e.date.isAfter(goalPinDate))) + .toList(); + + @override + void initState() { + super.initState(); + user = Provider.of(context, listen: false); + db = Provider.of(context, listen: false); + + WidgetsBinding.instance.addPostFrameCallback((_) { + fetchGoalAverages(); + fetchGoalPlan(); + }); + } + + @override + Widget build(BuildContext context) { + gradeProvider = Provider.of(context); + settingsProvider = Provider.of(context); + + var subjectGrades = getSubjectGrades(widget.subject).toList(); + currAvg = AverageHelper.averageEvals(subjectGrades); + + var afterGoalGrades = getAfterGoalGrades(widget.subject).toList(); + afterAvg = AverageHelper.averageEvals(afterGoalGrades); + + 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(afterGoalGrades, + 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: ListView( + padding: EdgeInsets.zero, + children: [ + Container( + decoration: const BoxDecoration( + // image: DecorationImage( + // image: + // AssetImage('assets/images/subject_covers/math_light.png'), + // fit: BoxFit.fitWidth, + // alignment: Alignment.topCenter, + // ), + ), + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Theme.of(context).scaffoldBackgroundColor.withOpacity(0.2), + Theme.of(context).scaffoldBackgroundColor, + ], + stops: const [ + 0.1, + 0.22, + ], + ), + ), + child: Padding( + padding: const EdgeInsets.only( + top: 60.0, + left: 2.0, + right: 2.0, + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const BackButton(), + IconButton( + onPressed: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0)), + title: Text("attention".i18n), + content: Text("attention_body".i18n), + actions: [ + ActionButton( + label: "delete".i18n, + onTap: () async { + // clear the goal + await Provider.of(context, + listen: false) + .clearGoal(widget.subject); + // close the modal and the goal page + Navigator.of(context).pop(); + Navigator.of(context).pop(); + }, + ), + ], + ), + ); + }, + icon: const Icon(FeatherIcons.x), + ), + ], + ), + const SizedBox(height: 22.0), + Column( + children: [ + RoundBorderIcon( + icon: Icon( + SubjectIcon.resolveVariant( + context: context, + subject: widget.subject, + ), + size: 26.0, + weight: 2.5, + ), + padding: 8.0, + width: 2.5, + ), + const SizedBox( + height: 10.0, + ), + Text( + (widget.subject.isRenamed + ? widget.subject.renamedTo + : widget.subject.name) ?? + 'goal_planner_title'.i18n, + style: const TextStyle( + fontSize: 30.0, + fontWeight: FontWeight.w700, + ), + ), + Text( + 'almost_there'.i18n, + style: const TextStyle( + fontSize: 22.0, + fontWeight: FontWeight.w400, + height: 1.0, + ), + ), + ], + ), + const SizedBox(height: 28.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'started_with'.i18n, + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 20.0, + ), + ), + const SizedBox(width: 5.0), + AverageDisplay(average: beforeAvg), + ], + ), + Row( + children: [ + Text( + 'current'.i18n, + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 20.0, + ), + ), + const SizedBox(width: 5.0), + AverageDisplay(average: currAvg), + const SizedBox(width: 5.0), + // ide majd kell average difference + ], + ), + ], + ), + ), + const SizedBox(height: 10.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Panel( + padding: const EdgeInsets.all(18.0), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'your_goal'.i18n, + style: const TextStyle( + fontSize: 23.0, + fontWeight: FontWeight.w700, + ), + ), + RawMaterialButton( + onPressed: () async { + Navigator.of(context).push( + CupertinoPageRoute( + builder: (context) => + GoalPlannerScreen( + subject: widget.subject))); + }, + fillColor: Colors.black, + shape: const StadiumBorder(), + padding: const EdgeInsets.symmetric( + horizontal: 18.0), + child: Text( + "change_it".i18n, + style: const TextStyle( + height: 1.0, + color: Colors.white, + fontSize: 14.0, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + goalAvg.toString(), + style: const TextStyle( + height: 1.1, + fontSize: 42.0, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(width: 10.0), + Center( + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 5.0, + horizontal: 8.0, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(45.0), + color: avgDifference.isNegative + ? Colors.redAccent.shade400 + .withOpacity(.15) + : Colors.greenAccent.shade700 + .withOpacity(.15), + ), + child: Row( + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + Icon( + avgDifference.isNegative + ? FeatherIcons.chevronDown + : FeatherIcons.chevronUp, + color: avgDifference.isNegative + ? Colors.redAccent.shade400 + : Colors.greenAccent.shade700, + size: 18.0, + ), + const SizedBox(width: 5.0), + Text( + avgDifference.toStringAsFixed(2) + + '%', + textAlign: TextAlign.center, + style: TextStyle( + color: avgDifference.isNegative + ? Colors.redAccent.shade400 + : Colors.greenAccent.shade700, + fontSize: 22.0, + height: 0.8, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 5.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: gradeGraph, + ), + const SizedBox(height: 5.0), + Padding( + padding: const EdgeInsets.only( + left: 12.0, + right: 12.0, + top: 5.0, + bottom: 8.0, + ), + child: Panel( + padding: const EdgeInsets.all(18.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'you_need'.i18n, + style: const TextStyle( + fontSize: 23.0, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 8.0), + plan != null + ? RouteOptionRow( + plan: plan!, + ) + : const Text(''), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui/mobile/goal_planner/goal_state_screen.i18n.dart b/lib/ui/mobile/goal_planner/goal_state_screen.i18n.dart new file mode 100644 index 0000000..19c56ed --- /dev/null +++ b/lib/ui/mobile/goal_planner/goal_state_screen.i18n.dart @@ -0,0 +1,87 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + // base page + "goal_planner_title": "Goal Planning", + "almost_there": "Almost there! Keep going!", + "started_with": "Started with:", + "current": "Current:", + "your_goal": "Your goal:", + "change_it": "Change it", + "look_at_graph": "Look at this graph!", + "thats_progress": + "Now that's what I call progress! Push a little more, you're almost there..", + "you_need": "You need:", + // done modal + "congrats_title": "🎉 Congratulations!", + "goal_reached": "You reached your goal after %s days!", + "started_at": "You started at", + "improved_by": "and improved your grade by %s", + "detailed_stats": "See my detailed stats", + "later": "Yay! I'll see my stats later.", + // sure delete modal + "delete": "Delete", + "attention": "Attention!", + "attention_body": + "Your goal and progress will be lost forever and cannot be restored.", + }, + "hu_hu": { + // base page + "goal_planner_title": "Cél követés", + "almost_there": "Majdnem megvan! Így tovább!", + "started_with": "Így kezdődött:", + "current": "Jelenlegi:", + "your_goal": "Célod:", + "change_it": "Megváltoztatás", + "look_at_graph": "Nézd meg ezt a grafikont!", + "thats_progress": + "Ezt nevezem haladásnak! Hajts még egy kicsit, már majdnem kész..", + "you_need": "Szükséges:", + // done modal + "congrats_title": "🎉 Gratulálunk!", + "goal_reached": "%s nap után érted el a célod!", + "started_at": "Átlagod kezdéskor:", + "improved_by": "%s-os javulást értél el!", + "detailed_stats": "Részletes statisztikám", + "later": "Hurrá! Megnézem máskor.", + // sure delete modal + "delete": "Törlés", + "attention": "Figyelem!", + "attention_body": + "A kitűzött célod és haladásod örökre elveszik és nem lesz visszaállítható.", + }, + "de_de": { + // base page + "goal_planner_title": "Zielplanung", + "almost_there": "Fast dort! Weitermachen!", + "started_with": "Begann mit:", + "current": "Aktuell:", + "your_goal": "Dein Ziel:", + "change_it": "Ändern Sie es", + "look_at_graph": "Schauen Sie sich diese Grafik an!", + "thats_progress": + "Das nenne ich Fortschritt! Drücken Sie noch ein wenig, Sie haben es fast geschafft..", + "you_need": "Du brauchst:", + // done modal + "congrats_title": "🎉 Glückwunsch!", + "goal_reached": "Du hast dein Ziel nach %s Tagen erreicht!", + "started_at": "Gesamtbewertung:", + "improved_by": "Sie haben %s Verbesserung erreicht!", + "detailed_stats": "Detaillierte Statistiken", + "later": "Hurra! Ich schaue später nach.", + // sure delete modal + "delete": "Löschen", + "attention": "Achtung!", + "attention_body": + "Ihr Ziel und Ihr Fortschritt gehen für immer verloren und können nicht wiederhergestellt werden.", + }, + }; + + 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); +} diff --git a/lib/ui/mobile/goal_planner/grade_display.dart b/lib/ui/mobile/goal_planner/grade_display.dart new file mode 100644 index 0000000..88f2181 --- /dev/null +++ b/lib/ui/mobile/goal_planner/grade_display.dart @@ -0,0 +1,34 @@ +import 'package:filcnaplo/models/settings.dart'; +import 'package:filcnaplo_premium/ui/mobile/goal_planner/goal_input.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class GradeDisplay extends StatelessWidget { + const GradeDisplay({Key? key, required this.grade}) : super(key: key); + + final int grade; + + @override + Widget build(BuildContext context) { + SettingsProvider settings = Provider.of(context); + + return Container( + width: 36, + height: 36, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: gradeColor(grade, settings).withOpacity(.3), + ), + child: Center( + child: Text( + grade.toInt().toString(), + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 22.0, + color: gradeColor(grade, settings), + ), + ), + ), + ); + } +} diff --git a/lib/ui/mobile/goal_planner/graph.dart b/lib/ui/mobile/goal_planner/graph.dart new file mode 100644 index 0000000..724b84a --- /dev/null +++ b/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/lib/ui/mobile/goal_planner/graph.i18n.dart b/lib/ui/mobile/goal_planner/graph.i18n.dart new file mode 100644 index 0000000..50e2ea8 --- /dev/null +++ b/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); +} diff --git a/lib/ui/mobile/goal_planner/route_option.dart b/lib/ui/mobile/goal_planner/route_option.dart new file mode 100644 index 0000000..bb1a1a7 --- /dev/null +++ b/lib/ui/mobile/goal_planner/route_option.dart @@ -0,0 +1,202 @@ +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_premium/ui/mobile/goal_planner/goal_planner.dart'; +import 'package:filcnaplo_premium/ui/mobile/goal_planner/goal_planner_screen.i18n.dart'; +import 'package:filcnaplo_premium/ui/mobile/goal_planner/grade_display.dart'; +import 'package:flutter/material.dart'; + +enum RouteMark { recommended, fastest } + +class RouteOption extends StatelessWidget { + const RouteOption( + {Key? key, + required this.plan, + this.mark, + this.selected = false, + required this.onSelected}) + : super(key: key); + + final Plan plan; + final RouteMark? mark; + final bool selected; + final void Function() onSelected; + + Widget markLabel() { + const style = TextStyle(fontWeight: FontWeight.bold); + + switch (mark!) { + case RouteMark.recommended: + return Text("recommended".i18n, style: style); + case RouteMark.fastest: + return Text("fastest".i18n, style: style); + } + } + + Color markColor(BuildContext context) { + switch (mark) { + case RouteMark.recommended: + return const Color.fromARGB(255, 104, 93, 255); + case RouteMark.fastest: + return const Color.fromARGB(255, 255, 91, 146); + default: + return Theme.of(context).colorScheme.primary; + } + } + + @override + Widget build(BuildContext context) { + List gradeWidgets = []; + + for (int i = 5; i > 1; i--) { + final count = plan.plan.where((e) => e == i).length; + + if (count > 4) { + gradeWidgets.add(Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "${count}x", + style: TextStyle( + fontSize: 22.0, + fontWeight: FontWeight.w500, + color: AppColors.of(context).text.withOpacity(.7), + ), + ), + const SizedBox(width: 4.0), + GradeDisplay(grade: i), + ], + )); + } else { + gradeWidgets + .addAll(List.generate(count, (_) => GradeDisplay(grade: i))); + } + + if (count > 0) { + gradeWidgets.add(SizedBox( + height: 36.0, + width: 32.0, + child: Center( + child: Icon(Icons.add, + color: AppColors.of(context).text.withOpacity(.5))), + )); + } + } + + gradeWidgets.removeLast(); + + return Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: SizedBox( + width: double.infinity, + child: Card( + surfaceTintColor: + selected ? markColor(context).withOpacity(.2) : Colors.white, + margin: EdgeInsets.zero, + elevation: 5, + shadowColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16.0), + side: selected + ? BorderSide(color: markColor(context), width: 4.0) + : BorderSide.none, + ), + child: InkWell( + borderRadius: BorderRadius.circular(16.0), + onTap: onSelected, + child: Padding( + padding: const EdgeInsets.only( + top: 16.0, bottom: 16.0, left: 20.0, right: 12.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (mark != null) ...[ + Chip( + label: markLabel(), + visualDensity: VisualDensity.compact, + backgroundColor: + selected ? markColor(context) : Colors.transparent, + labelPadding: const EdgeInsets.symmetric(horizontal: 8.0), + labelStyle: + TextStyle(color: selected ? Colors.white : null), + shape: StadiumBorder( + side: BorderSide( + color: markColor(context), + width: 3.0, + ), + ), + ), + const SizedBox(height: 6.0), + ], + Wrap( + spacing: 4.0, + runSpacing: 8.0, + children: gradeWidgets, + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class RouteOptionRow extends StatelessWidget { + const RouteOptionRow({ + Key? key, + required this.plan, + this.mark, + }) : super(key: key); + + final Plan plan; + final RouteMark? mark; + + @override + Widget build(BuildContext context) { + List gradeWidgets = []; + + for (int i = 5; i > 1; i--) { + final count = plan.plan.where((e) => e == i).length; + + if (count > 4) { + gradeWidgets.add(Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "${count}x", + style: TextStyle( + fontSize: 22.0, + fontWeight: FontWeight.w500, + color: AppColors.of(context).text.withOpacity(.7), + ), + ), + const SizedBox(width: 4.0), + GradeDisplay(grade: i), + ], + )); + } else { + gradeWidgets + .addAll(List.generate(count, (_) => GradeDisplay(grade: i))); + } + + if (count > 0) { + gradeWidgets.add(SizedBox( + height: 36.0, + width: 32.0, + child: Center( + child: Icon(Icons.add, + color: AppColors.of(context).text.withOpacity(.5))), + )); + } + } + + gradeWidgets.removeLast(); + + return Wrap( + spacing: 4.0, + runSpacing: 8.0, + children: gradeWidgets, + ); + } +} diff --git a/lib/ui/mobile/premium/activation_view/activation_dashboard.dart b/lib/ui/mobile/premium/activation_view/activation_dashboard.dart new file mode 100644 index 0000000..3577588 --- /dev/null +++ b/lib/ui/mobile/premium/activation_view/activation_dashboard.dart @@ -0,0 +1,197 @@ +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_premium/providers/premium_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +class ActivationDashboard extends StatefulWidget { + const ActivationDashboard({super.key}); + + @override + State createState() => _ActivationDashboardState(); +} + +class _ActivationDashboardState extends State { + bool manualActivationLoading = false; + + Future onManualActivation() async { + final data = await Clipboard.getData("text/plain"); + if (data == null || data.text == null || data.text == "") { + return; + } + setState(() { + manualActivationLoading = true; + }); + final result = + await context.read().auth.finishAuth(data.text!); + setState(() { + manualActivationLoading = false; + }); + + if (!result && mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text( + "Sikertelen aktiválás. Kérlek próbáld újra később!", + style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold), + ), + backgroundColor: Colors.red, + )); + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Spacer(), + Center( + child: SvgPicture.asset( + "assets/images/github.svg", + height: 64.0, + ), + ), + const SizedBox(height: 32.0), + const Text( + "Jelentkezz be a GitHub felületén és adj hozzáférést a Filcnek, hogy aktiváld a Premiumot.", + textAlign: TextAlign.center, + style: TextStyle(fontWeight: FontWeight.w700, fontSize: 18.0), + ), + const SizedBox(height: 12.0), + Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14.0)), + child: const Padding( + padding: EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(FeatherIcons.alertTriangle, + size: 20.0, color: Colors.orange), + SizedBox(width: 12.0), + Text( + "Figyelem!", + style: TextStyle( + fontSize: 18.0, fontWeight: FontWeight.bold), + ), + ], + ), + SizedBox(height: 6.0), + Text( + "Csak akkor érzékeli a Filc a támogatói státuszod, ha nem állítod privátra!", + style: TextStyle(fontSize: 16.0), + ), + ], + ), + ), + ), + const SizedBox(height: 12.0), + Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14.0)), + child: const Padding( + padding: EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(FeatherIcons.alertTriangle, + size: 20.0, color: Colors.orange), + SizedBox(width: 12.0), + Text( + "Figyelem!", + style: TextStyle( + fontSize: 18.0, fontWeight: FontWeight.bold), + ), + ], + ), + SizedBox(height: 6.0), + Text( + "Ha friss támogató vagy, 5-10 percbe telhet az aktiválás. Kérlek gyere vissza később, és próbáld újra!", + style: TextStyle(fontSize: 16.0), + ), + ], + ), + ), + ), + const SizedBox(height: 12.0), + Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14.0)), + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Ha bejelentkezés után nem lép vissza az alkalmazásba automatikusan, aktiváld a támogatásod manuálisan", + style: + TextStyle(fontSize: 15.0, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 6.0), + Center( + child: TextButton.icon( + onPressed: onManualActivation, + style: ButtonStyle( + foregroundColor: MaterialStatePropertyAll( + Theme.of(context).colorScheme.secondary), + overlayColor: MaterialStatePropertyAll(Theme.of(context) + .colorScheme + .secondary + .withOpacity(.1)), + ), + icon: manualActivationLoading + ? const SizedBox( + child: CircularProgressIndicator(), + height: 16.0, + width: 16.0, + ) + : const Icon(FeatherIcons.key, size: 20.0), + label: const Padding( + padding: EdgeInsets.only(left: 8.0), + child: Text( + "Aktiválás tokennel", + style: TextStyle(fontSize: 16.0), + ), + ), + ), + ), + ], + ), + ), + ), + const Spacer(), + Padding( + padding: const EdgeInsets.only(bottom: 24.0), + child: Center( + child: TextButton.icon( + onPressed: () { + Navigator.of(context).pop(); + }, + style: ButtonStyle( + foregroundColor: + MaterialStatePropertyAll(AppColors.of(context).text), + overlayColor: MaterialStatePropertyAll( + AppColors.of(context).text.withOpacity(.1)), + ), + icon: const Icon(FeatherIcons.arrowLeft, size: 20.0), + label: const Text( + "Vissza", + style: TextStyle(fontSize: 16.0), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui/mobile/premium/activation_view/activation_view.dart b/lib/ui/mobile/premium/activation_view/activation_view.dart new file mode 100644 index 0000000..232f0fb --- /dev/null +++ b/lib/ui/mobile/premium/activation_view/activation_view.dart @@ -0,0 +1,72 @@ +import 'package:animations/animations.dart'; +import 'package:filcnaplo_premium/providers/premium_provider.dart'; +import 'package:filcnaplo_premium/ui/mobile/premium/activation_view/activation_dashboard.dart'; +import 'package:flutter/material.dart'; +import 'package:lottie/lottie.dart'; +import 'package:provider/provider.dart'; + +class PremiumActivationView extends StatefulWidget { + const PremiumActivationView({super.key}); + + @override + State createState() => _PremiumActivationViewState(); +} + +class _PremiumActivationViewState extends State + with SingleTickerProviderStateMixin { + late AnimationController animation; + bool activated = false; + + @override + void initState() { + super.initState(); + context.read().auth.initAuth(); + + animation = + AnimationController(vsync: this, duration: const Duration(seconds: 2)); + } + + @override + void dispose() { + animation.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final premium = context.watch(); + + if (premium.hasPremium && !activated) { + activated = true; + animation.forward(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + Future.delayed(const Duration(seconds: 2)).then((value) { + if (mounted) Navigator.of(context).pop(); + }); + }); + } + + return Scaffold( + body: PageTransitionSwitcher( + transitionBuilder: (child, primaryAnimation, secondaryAnimation) => + SharedAxisTransition( + animation: primaryAnimation, + secondaryAnimation: secondaryAnimation, + transitionType: SharedAxisTransitionType.horizontal, + fillColor: Colors.transparent, + child: child, + ), + child: premium.hasPremium + ? Center( + child: SizedBox( + width: 400, + child: Lottie.network( + "https://assets2.lottiefiles.com/packages/lf20_wkebwzpz.json", + controller: animation), + ), + ) + : const SafeArea(child: ActivationDashboard()), + ), + ); + } +} diff --git a/lib/ui/mobile/premium/premium_inline.dart b/lib/ui/mobile/premium/premium_inline.dart new file mode 100644 index 0000000..e5ab39f --- /dev/null +++ b/lib/ui/mobile/premium/premium_inline.dart @@ -0,0 +1,69 @@ +import 'package:filcnaplo_premium/ui/mobile/premium/upsell.dart'; +import 'package:flutter/material.dart'; + +enum PremiumInlineFeature { nickname, theme, widget, goal, stats } + +const Map _featureAssets = { + PremiumInlineFeature.nickname: + "assets/images/premium_nickname_inline_showcase.png", + PremiumInlineFeature.theme: "assets/images/premium_theme_inline_showcase.png", + PremiumInlineFeature.widget: + "assets/images/premium_widget_inline_showcase.png", + PremiumInlineFeature.goal: "assets/images/premium_goal_inline_showcase.png", + PremiumInlineFeature.stats: "assets/images/premium_stats_inline_showcase.png", +}; + +const Map _featuresInline = { + PremiumInlineFeature.nickname: PremiumFeature.profile, + PremiumInlineFeature.theme: PremiumFeature.customcolors, + PremiumInlineFeature.widget: PremiumFeature.widget, + PremiumInlineFeature.goal: PremiumFeature.goalplanner, + PremiumInlineFeature.stats: PremiumFeature.gradestats, +}; + +class PremiumInline extends StatelessWidget { + const PremiumInline({super.key, required this.features}); + + final List features; + + String _getAsset() { + for (int i = 0; i < features.length; i++) { + if (DateTime.now().day % features.length == i) { + return _featureAssets[features[i]]!; + } + } + + return _featureAssets[features[0]]!; + } + + PremiumFeature _getFeature() { + for (int i = 0; i < features.length; i++) { + if (DateTime.now().day % features.length == i) { + return _featuresInline[features[i]]!; + } + } + + return _featuresInline[features[0]]!; + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Image.asset(_getAsset()), + Positioned.fill( + child: Material( + type: MaterialType.transparency, + child: InkWell( + borderRadius: BorderRadius.circular(16.0), + onTap: () { + PremiumLockedFeatureUpsell.show( + context: context, feature: _getFeature()); + }, + ), + ), + ), + ], + ); + } +} diff --git a/lib/ui/mobile/premium/upsell.dart b/lib/ui/mobile/premium/upsell.dart new file mode 100644 index 0000000..94573e0 --- /dev/null +++ b/lib/ui/mobile/premium/upsell.dart @@ -0,0 +1,194 @@ +import 'package:filcnaplo/icons/filc_icons.dart'; +import 'package:filcnaplo_mobile_ui/premium/premium_screen.dart'; +import 'package:flutter/material.dart'; + +enum PremiumFeature { + // old things + gradestats, + customcolors, + profile, + iconpack, + subjectrename, + weeklytimetable, + widget, + // new things + teacherrename, + goalplanner, + appiconchange, +} + +enum PremiumFeatureLevel { + old, + cap, + ink, + sponge, +} + +const Map _featureLevels = { + // old things + PremiumFeature.gradestats: PremiumFeatureLevel.old, + PremiumFeature.customcolors: PremiumFeatureLevel.old, + PremiumFeature.profile: PremiumFeatureLevel.old, + PremiumFeature.iconpack: PremiumFeatureLevel.old, + PremiumFeature.subjectrename: PremiumFeatureLevel.old, + PremiumFeature.weeklytimetable: PremiumFeatureLevel.old, + PremiumFeature.widget: PremiumFeatureLevel.old, + // new things + PremiumFeature.teacherrename: PremiumFeatureLevel.cap, + PremiumFeature.goalplanner: PremiumFeatureLevel.cap, +}; + +const Map _featureAssets = { + PremiumFeature.gradestats: "assets/images/premium_stats_showcase.png", + PremiumFeature.customcolors: "assets/images/premium_theme_showcase.png", + PremiumFeature.profile: "assets/images/premium_nickname_showcase.png", + PremiumFeature.weeklytimetable: + "assets/images/premium_timetable_showcase.png", + PremiumFeature.goalplanner: "assets/images/premium_goal_showcase.png", + PremiumFeature.widget: "assets/images/premium_widget_showcase.png", +}; + +const Map _featureTitles = { + PremiumFeature.gradestats: "Találtál egy prémium funkciót.", + PremiumFeature.customcolors: "Több személyre szabás kell?", + PremiumFeature.profile: "Nem tetszik a neved?", + PremiumFeature.iconpack: "Jobban tetszettek a régi ikonok?", + PremiumFeature.subjectrename: + "Sokáig tart elolvasni, hogy \"Földrajz természettudomány\"?", + PremiumFeature.weeklytimetable: "Szeretnéd egyszerre az egész hetet látni?", + PremiumFeature.goalplanner: "Kövesd a céljaidat, sok-sok statisztikával.", + PremiumFeature.widget: "Órák a kezdőképernyőd kényelméből.", +}; + +const Map _featureDescriptions = { + PremiumFeature.gradestats: + "Támogass Kupak szinten, hogy több statisztikát láthass. ", + PremiumFeature.customcolors: + "Támogass Kupak szinten, és szabd személyre az elemek, a háttér, és a panelek színeit.", + PremiumFeature.profile: + "Kupak szinten változtathatod a nevedet, sőt, akár a profilképedet is.", + PremiumFeature.iconpack: + "Támogass Kupak szinten, hogy ikon témát választhass.", + PremiumFeature.subjectrename: + "Támogass Kupak szinten, hogy átnevezhesd Föcire.", + PremiumFeature.weeklytimetable: + "Támogass Tinta szinten a heti órarend funkcióért.", + PremiumFeature.goalplanner: "A célkövetéshez támogass Tinta szinten.", + PremiumFeature.widget: + "Támogass Tinta szinten, és helyezz egy widgetet a kezdőképernyődre.", +}; + +class PremiumLockedFeatureUpsell extends StatelessWidget { + const PremiumLockedFeatureUpsell({super.key, required this.feature}); + + static void show( + {required BuildContext context, required PremiumFeature feature}) => + showDialog( + context: context, + builder: (context) => PremiumLockedFeatureUpsell(feature: feature)); + + final PremiumFeature feature; + + IconData _getIcon() => _featureLevels[feature] == PremiumFeatureLevel.cap + ? FilcIcons.kupak + : FilcIcons.tinta; + Color _getColor(BuildContext context) => + _featureLevels[feature] == PremiumFeatureLevel.cap + ? const Color(0xffC8A708) + : Theme.of(context).brightness == Brightness.light + ? const Color(0xff691A9B) + : const Color(0xffA66FC8); + String? _getAsset() => _featureAssets[feature]; + String _getTitle() => _featureTitles[feature]!; + String _getDescription() => _featureDescriptions[feature]!; + + @override + Widget build(BuildContext context) { + final Color color = _getColor(context); + + return Dialog( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title Bar + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Icon(_getIcon()), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + + // Image showcase + if (_getAsset() != null) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Image.asset(_getAsset()!), + ), + + // Dialog title + Padding( + padding: const EdgeInsets.only(top: 12.0), + child: Text( + _getTitle(), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 20.0, + ), + ), + ), + + // Dialog description + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + _getDescription(), + style: const TextStyle( + fontSize: 16.0, + ), + ), + ), + + // CTA button + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: SizedBox( + width: double.infinity, + child: TextButton( + style: ButtonStyle( + backgroundColor: + MaterialStatePropertyAll(color.withOpacity(.25)), + foregroundColor: MaterialStatePropertyAll(color), + overlayColor: + MaterialStatePropertyAll(color.withOpacity(.1))), + onPressed: () { + Navigator.of(context, rootNavigator: true) + .push(MaterialPageRoute(builder: (context) { + return const PremiumScreen(); + })); + }, + child: const Text( + "Vigyél oda!", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18.0, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/ui/mobile/settings/app_icon_screen.dart b/lib/ui/mobile/settings/app_icon_screen.dart new file mode 100644 index 0000000..fbd45c3 --- /dev/null +++ b/lib/ui/mobile/settings/app_icon_screen.dart @@ -0,0 +1,240 @@ +import 'package:filcnaplo/models/settings.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_mobile_ui/common/panel/panel.dart'; +import 'package:filcnaplo_mobile_ui/common/panel/panel_button.dart'; +import 'package:filcnaplo_premium/helpers/app_icon_helper.dart'; +import 'package:filcnaplo_premium/models/premium_scopes.dart'; +import 'package:filcnaplo_premium/providers/premium_provider.dart'; +import 'package:filcnaplo_premium/ui/mobile/premium/upsell.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'package:provider/provider.dart'; +import 'app_icon_screen.i18n.dart'; + +class PremiumCustomAppIconMenu extends StatelessWidget { + const PremiumCustomAppIconMenu({Key? key, required this.settings}) + : super(key: key); + + final SettingsProvider settings; + + @override + Widget build(BuildContext context) { + return PanelButton( + onPressed: () { + if (!Provider.of(context, listen: false) + .hasScope(PremiumScopes.changeAppIcon)) { + PremiumLockedFeatureUpsell.show( + context: context, feature: PremiumFeature.appiconchange); + return; + } + + Navigator.of(context, rootNavigator: true).push( + CupertinoPageRoute(builder: (context) => const ModifyAppIcon()), + ); + }, + title: Text('custom_app_icon'.i18n), + leading: const Icon(FeatherIcons.edit), + ); + } +} + +class ModifyAppIcon extends StatefulWidget { + const ModifyAppIcon({Key? key}) : super(key: key); + + @override + State createState() => _ModifyAppIconState(); +} + +class _ModifyAppIconState extends State { + final GlobalKey _scaffoldKey = GlobalKey(); + + late SettingsProvider settings; + + @override + Widget build(BuildContext context) { + settings = Provider.of(context); + return Scaffold( + key: _scaffoldKey, + appBar: AppBar( + surfaceTintColor: Theme.of(context).scaffoldBackgroundColor, + leading: BackButton(color: AppColors.of(context).text), + title: Text( + "app_icons".i18n, + style: TextStyle(color: AppColors.of(context).text), + ), + ), + body: Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Panel( + title: Text("basic".i18n), + child: Column( + children: [ + AppIconItem( + iconName: 'refilc_default', + iconPath: 'assets/launch_icons/refilc_default.png', + displayName: 'reFilc Default', + description: 'Az alapértelmezett ikon.', + selected: settings.appIcon == 'refilc_default', + selectCallback: () async { + await AppIconHelper.setAppIcon('refilc_default'); + settings.update(appIcon: 'refilc_default'); + }, + ), + ], + ), + ), + // const SizedBox(height: 16.0), + // Panel( + // title: Text("seasonal".i18n), + // child: Column( + // children: [ + // // AppIconItem( + // // iconName: 'refilc_default', + // // iconPath: 'assets/launch_icons/refilc_default.png', + // // displayName: 'reFilc Default', + // // description: 'Az alapértelmezett ikon.', + // // selected: true, + // // selectCallback: () {}, + // // ), + // ], + // ), + // ), + const SizedBox(height: 16.0), + Panel( + title: Text("special".i18n), + child: Column( + children: [ + AppIconItem( + iconName: 'refilc_overcomplicated', + iconPath: + 'assets/launch_icons/refilc_overcomplicated.png', + displayName: 'Overcomplicated', + // description: 'Egy túlkomplikált ikon.', + selected: settings.appIcon == 'refilc_overcomplicated', + selectCallback: () async { + await AppIconHelper.setAppIcon( + 'refilc_overcomplicated'); + settings.update(appIcon: 'refilc_overcomplicated'); + }, + ), + AppIconItem( + iconName: 'refilc_concept', + iconPath: 'assets/launch_icons/refilc_concept.png', + displayName: 'Modern Concept', + // description: 'Egy modernebb, letisztultabb ikon.', + selected: settings.appIcon == 'refilc_concept', + selectCallback: () async { + await AppIconHelper.setAppIcon('refilc_concept'); + settings.update(appIcon: 'refilc_concept'); + }, + ), + ], + ), + ), + const SizedBox(height: 16.0), + Panel( + title: Text("other".i18n), + child: Column( + children: [ + AppIconItem( + iconName: 'refilc_pride', + iconPath: 'assets/launch_icons/refilc_pride.png', + displayName: 'Pride', + // description: '', + selected: settings.appIcon == 'refilc_pride', + selectCallback: () async { + await AppIconHelper.setAppIcon('refilc_pride'); + settings.update(appIcon: 'refilc_pride'); + }, + ), + ], + ), + ), + ], + ), + ), + )); + } +} + +class AppIconItem extends StatelessWidget { + const AppIconItem({ + Key? key, + required this.iconName, + required this.iconPath, + required this.displayName, + this.description, + required this.selected, + required this.selectCallback, + }) : super(key: key); + + final String iconName; + final String iconPath; + final String displayName; + final String? description; + final bool selected; + final void Function() selectCallback; + + @override + Widget build(BuildContext context) { + return ListTile( + minLeadingWidth: 32.0, + dense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)), + visualDensity: VisualDensity.compact, + onTap: () {}, + leading: Container( + height: 40, + width: 40, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10.0), + image: DecorationImage( + image: AssetImage(iconPath), + fit: BoxFit.contain, + ), + ), + ), + title: InkWell( + onTap: selectCallback, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + displayName, + style: TextStyle( + fontWeight: FontWeight.w500, + fontSize: 16, + height: description == null ? 3.2 : 1.8, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (description != null) + Text( + description!, + style: TextStyle( + fontWeight: FontWeight.w500, + fontSize: 14, + color: AppColors.of(context).text.withOpacity(.75), + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + trailing: selected + ? Icon( + FeatherIcons.checkCircle, + color: Theme.of(context).colorScheme.primary, + ) + : const SizedBox(), + ); + } +} diff --git a/lib/ui/mobile/settings/app_icon_screen.i18n.dart b/lib/ui/mobile/settings/app_icon_screen.i18n.dart new file mode 100644 index 0000000..e12b283 --- /dev/null +++ b/lib/ui/mobile/settings/app_icon_screen.i18n.dart @@ -0,0 +1,36 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "custom_app_icon": "Custom App Icon", + "app_icons": "App Icons", + "basic": "Basic", + "seasonal": "Seasonal", + "special": "Special", + "other": "Other", + }, + "hu_hu": { + "custom_app_icon": "Alkalmazásikon", + "app_icons": "Alkalmazásikonok", + "basic": "Egyszerű", + "seasonal": "Szezonális", + "special": "Különleges", + "other": "Egyéb", + }, + "de_de": { + "custom_app_icon": "App-Symbol", + "app_icons": "App-Symbole", + "basic": "Basic", + "seasonal": "Saisonal", + "special": "Besonders", + "other": "Andere", + }, + }; + + 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); +} diff --git a/lib/ui/mobile/settings/modify_teacher_names.dart b/lib/ui/mobile/settings/modify_teacher_names.dart new file mode 100644 index 0000000..b9e8a28 --- /dev/null +++ b/lib/ui/mobile/settings/modify_teacher_names.dart @@ -0,0 +1,436 @@ +import 'package:dropdown_button2/dropdown_button2.dart'; +import 'package:filcnaplo/api/providers/database_provider.dart'; +import 'package:filcnaplo/api/providers/user_provider.dart'; +import 'package:filcnaplo/models/settings.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo/utils/format.dart'; +import 'package:filcnaplo_kreta_api/models/teacher.dart'; +import 'package:filcnaplo_kreta_api/providers/absence_provider.dart'; +import 'package:filcnaplo_kreta_api/providers/grade_provider.dart'; +import 'package:filcnaplo_kreta_api/providers/timetable_provider.dart'; +import 'package:filcnaplo_mobile_ui/common/panel/panel.dart'; +import 'package:filcnaplo_mobile_ui/common/panel/panel_button.dart'; +import 'package:filcnaplo_premium/models/premium_scopes.dart'; +import 'package:filcnaplo_premium/providers/premium_provider.dart'; +import 'package:filcnaplo_premium/ui/mobile/premium/upsell.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'package:provider/provider.dart'; +import 'package:filcnaplo_mobile_ui/screens/settings/modify_names.i18n.dart'; + +class MenuRenamedTeachers extends StatelessWidget { + const MenuRenamedTeachers({Key? key, required this.settings}) + : super(key: key); + + final SettingsProvider settings; + + @override + Widget build(BuildContext context) { + return PanelButton( + padding: const EdgeInsets.only(left: 14.0), + onPressed: () { + if (!Provider.of(context, listen: false) + .hasScope(PremiumScopes.renameTeachers)) { + PremiumLockedFeatureUpsell.show( + context: context, feature: PremiumFeature.teacherrename); + return; + } + + Navigator.of(context, rootNavigator: true).push( + CupertinoPageRoute(builder: (context) => const ModifyTeacherNames()), + ); + }, + title: Text( + "rename_teachers".i18n, + style: TextStyle( + color: AppColors.of(context) + .text + .withOpacity(settings.renamedTeachersEnabled ? 1.0 : .5)), + ), + leading: settings.renamedTeachersEnabled + ? const Icon(FeatherIcons.users) + : Icon(FeatherIcons.users, + color: AppColors.of(context).text.withOpacity(.25)), + trailingDivider: true, + trailing: Switch( + onChanged: (v) async { + if (!Provider.of(context, listen: false) + .hasScope(PremiumScopes.renameTeachers)) { + PremiumLockedFeatureUpsell.show( + context: context, feature: PremiumFeature.teacherrename); + return; + } + + settings.update(renamedTeachersEnabled: v); + await Provider.of(context, listen: false) + .convertBySettings(); + await Provider.of(context, listen: false) + .convertBySettings(); + await Provider.of(context, listen: false) + .convertBySettings(); + }, + value: settings.renamedTeachersEnabled, + activeColor: Theme.of(context).colorScheme.secondary, + ), + ); + } +} + +class ModifyTeacherNames extends StatefulWidget { + const ModifyTeacherNames({Key? key}) : super(key: key); + + @override + State createState() => _ModifyTeacherNamesState(); +} + +class _ModifyTeacherNamesState extends State { + final GlobalKey _scaffoldKey = GlobalKey(); + final _teacherName = TextEditingController(); + String? selectedTeacherId; + + late List teachers; + late UserProvider user; + late DatabaseProvider dbProvider; + late SettingsProvider settings; + + @override + void initState() { + super.initState(); + teachers = (Provider.of(context, listen: false) + .grades + .map((e) => e.teacher) + .toSet() + .toList() + ..sort((a, b) => a.name.compareTo(b.name))); + user = Provider.of(context, listen: false); + dbProvider = Provider.of(context, listen: false); + } + + Future> fetchRenamedTeachers() async { + return await dbProvider.userQuery.renamedTeachers(userId: user.id!); + } + + void showRenameDialog() { + showDialog( + context: context, + builder: (context) => StatefulBuilder(builder: (context, setS) { + return AlertDialog( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(14.0))), + title: Text("rename_teacher".i18n), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownButton2( + items: teachers + .map((item) => DropdownMenuItem( + value: item.id, + child: Text( + item.name, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: AppColors.of(context).text, + ), + overflow: TextOverflow.ellipsis, + ), + )) + .toList(), + onChanged: (String? v) async { + final renamedSubs = await fetchRenamedTeachers(); + + setS(() { + selectedTeacherId = v; + + if (renamedSubs.containsKey(selectedTeacherId)) { + _teacherName.text = renamedSubs[selectedTeacherId]!; + } else { + _teacherName.text = ""; + } + }); + }, + iconSize: 14, + iconEnabledColor: AppColors.of(context).text, + iconDisabledColor: AppColors.of(context).text, + underline: const SizedBox(), + itemHeight: 40, + itemPadding: const EdgeInsets.only(left: 14, right: 14), + buttonWidth: 50, + dropdownWidth: 300, + dropdownPadding: null, + buttonDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + ), + dropdownDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + ), + dropdownElevation: 8, + scrollbarRadius: const Radius.circular(40), + scrollbarThickness: 6, + scrollbarAlwaysShow: true, + offset: const Offset(-10, -10), + buttonSplashColor: Colors.transparent, + customButton: Container( + width: double.infinity, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey, width: 2), + borderRadius: BorderRadius.circular(12.0), + ), + padding: const EdgeInsets.symmetric( + vertical: 12.0, horizontal: 8.0), + child: Text( + selectedTeacherId == null + ? "select_teacher".i18n + : teachers + .firstWhere( + (element) => element.id == selectedTeacherId) + .name, + style: Theme.of(context).textTheme.titleSmall!.copyWith( + fontWeight: FontWeight.w700, + color: AppColors.of(context).text.withOpacity(0.75)), + overflow: TextOverflow.ellipsis, + maxLines: 2, + textAlign: TextAlign.center, + ), + ), + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: Icon(FeatherIcons.arrowDown, size: 32), + ), + TextField( + controller: _teacherName, + decoration: InputDecoration( + border: OutlineInputBorder( + borderSide: + const BorderSide(color: Colors.grey, width: 1.5), + borderRadius: BorderRadius.circular(12.0), + ), + focusedBorder: OutlineInputBorder( + borderSide: + const BorderSide(color: Colors.grey, width: 1.5), + borderRadius: BorderRadius.circular(12.0), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12.0), + hintText: "modified_name".i18n, + suffixIcon: IconButton( + icon: const Icon( + FeatherIcons.x, + color: Colors.grey, + ), + onPressed: () { + setState(() { + _teacherName.text = ""; + }); + }, + ), + ), + ), + ], + ), + actions: [ + TextButton( + child: Text( + "cancel".i18n, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + onPressed: () { + Navigator.of(context).maybePop(); + }, + ), + TextButton( + child: Text( + "done".i18n, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + onPressed: () async { + if (selectedTeacherId != null) { + final renamedSubs = await fetchRenamedTeachers(); + + renamedSubs[selectedTeacherId!] = _teacherName.text; + await dbProvider.userStore + .storeRenamedTeachers(renamedSubs, userId: user.id!); + await Provider.of(context, listen: false) + .convertBySettings(); + await Provider.of(context, listen: false) + .convertBySettings(); + await Provider.of(context, listen: false) + .convertBySettings(); + } + Navigator.of(context).pop(true); + setState(() {}); + }, + ), + ], + ); + }), + ).then((val) { + _teacherName.text = ""; + selectedTeacherId = null; + }); + } + + @override + Widget build(BuildContext context) { + settings = Provider.of(context); + return Scaffold( + key: _scaffoldKey, + appBar: AppBar( + surfaceTintColor: Theme.of(context).scaffoldBackgroundColor, + leading: BackButton(color: AppColors.of(context).text), + title: Text( + "modify_teachers".i18n, + style: TextStyle(color: AppColors.of(context).text), + ), + ), + body: Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Panel( + // child: SwitchListTile( + + // title: Text("italics_toggle".i18n), + // onChanged: (value) => + // settings.update(renamedTeachersItalics: value), + // value: settings.renamedTeachersItalics, + // ), + // ), + // const SizedBox( + // height: 20, + // ), + InkWell( + onTap: showRenameDialog, + borderRadius: BorderRadius.circular(12.0), + child: Container( + width: double.infinity, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey, width: 2), + borderRadius: BorderRadius.circular(12.0), + ), + padding: const EdgeInsets.symmetric( + vertical: 18.0, horizontal: 12.0), + child: Center( + child: Text( + "rename_new_teacher".i18n, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 18, + color: AppColors.of(context).text.withOpacity(.85), + ), + ), + ), + ), + ), + const SizedBox( + height: 30, + ), + FutureBuilder>( + future: fetchRenamedTeachers(), + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data!.isEmpty) { + return Container(); + } + + return Panel( + title: Text("renamed_teachers".i18n), + child: Column( + children: snapshot.data!.keys.map( + (key) { + Teacher? teacher = teachers + .firstWhere((element) => key == element.id); + String renameTo = snapshot.data![key]!; + return RenamedTeacherItem( + teacher: teacher, + renamedTo: renameTo, + modifyCallback: () { + setState(() { + selectedTeacherId = teacher.id; + _teacherName.text = renameTo; + }); + showRenameDialog(); + }, + removeCallback: () { + setState(() { + Map subs = + Map.from(snapshot.data!); + subs.remove(key); + dbProvider.userStore.storeRenamedTeachers( + subs, + userId: user.id!); + }); + }, + ); + }, + ).toList(), + ), + ); + }, + ), + ], + ), + ), + )); + } +} + +class RenamedTeacherItem extends StatelessWidget { + const RenamedTeacherItem({ + Key? key, + required this.teacher, + required this.renamedTo, + required this.modifyCallback, + required this.removeCallback, + }) : super(key: key); + + final Teacher teacher; + final String renamedTo; + final void Function() modifyCallback; + final void Function() removeCallback; + + @override + Widget build(BuildContext context) { + return ListTile( + minLeadingWidth: 32.0, + dense: true, + contentPadding: + const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)), + visualDensity: VisualDensity.compact, + onTap: () {}, + leading: Icon(FeatherIcons.user, + color: AppColors.of(context).text.withOpacity(.75)), + title: InkWell( + onTap: modifyCallback, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + teacher.name.capital(), + style: TextStyle( + fontWeight: FontWeight.w500, + fontSize: 14, + color: AppColors.of(context).text.withOpacity(.75)), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + renamedTo, + style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 16), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + trailing: InkWell( + onTap: removeCallback, + child: Icon(FeatherIcons.trash, + color: AppColors.of(context).red.withOpacity(.75)), + ), + ); + } +} diff --git a/lib/ui/mobile/settings/share_theme.dart b/lib/ui/mobile/settings/share_theme.dart new file mode 100644 index 0000000..cbbe917 --- /dev/null +++ b/lib/ui/mobile/settings/share_theme.dart @@ -0,0 +1,26 @@ +import 'package:filcnaplo/models/settings.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class PremiumShareTheme extends StatefulWidget { + const PremiumShareTheme({Key? key}) : super(key: key); + + @override + State createState() => _PremiumShareThemeState(); +} + +class _PremiumShareThemeState extends State + with TickerProviderStateMixin { + late final SettingsProvider settingsProvider; + + @override + void initState() { + super.initState(); + settingsProvider = Provider.of(context, listen: false); + } + + @override + Widget build(BuildContext context) { + return const Scaffold(); + } +} diff --git a/lib/ui/mobile/settings/welcome_message.dart b/lib/ui/mobile/settings/welcome_message.dart new file mode 100644 index 0000000..456510d --- /dev/null +++ b/lib/ui/mobile/settings/welcome_message.dart @@ -0,0 +1,146 @@ +import 'package:filcnaplo/api/providers/user_provider.dart'; +import 'package:filcnaplo/models/settings.dart'; +import 'package:filcnaplo_mobile_ui/common/panel/panel_button.dart'; +import 'package:filcnaplo_premium/models/premium_scopes.dart'; +import 'package:filcnaplo_premium/providers/premium_provider.dart'; +import 'package:filcnaplo_premium/ui/mobile/premium/upsell.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'package:filcnaplo_mobile_ui/screens/settings/settings_screen.i18n.dart'; +import 'package:provider/provider.dart'; +import 'package:i18n_extension/i18n_extension.dart'; + +// ignore: must_be_immutable +class WelcomeMessagePanelButton extends StatelessWidget { + late SettingsProvider settingsProvider; + late UserProvider user; + + WelcomeMessagePanelButton(this.settingsProvider, this.user, {Key? key}) + : super(key: key); + + @override + Widget build(BuildContext context) { + String finalName = ((user.nickname ?? '') != '' + ? user.nickname + : (user.displayName ?? '') != '' + ? user.displayName + : 'János') ?? + 'János'; + + return PanelButton( + onPressed: () { + if (!Provider.of(context, listen: false) + .hasScope(PremiumScopes.all)) { + PremiumLockedFeatureUpsell.show( + context: context, feature: PremiumFeature.profile); + return; + } + showDialog( + context: context, + builder: (context) => WelcomeMessageEditor(settingsProvider)); + }, + title: Text("welcome_msg".i18n), + leading: const Icon(FeatherIcons.smile), + trailing: Container( + constraints: const BoxConstraints(maxWidth: 100), + child: Text( + settingsProvider.welcomeMessage.replaceAll(' ', '') != '' + ? localizeFill( + settingsProvider.welcomeMessage, + [finalName], + ) + : 'default'.i18n, + style: const TextStyle(fontSize: 14.0), + textAlign: TextAlign.end, + softWrap: true, + overflow: TextOverflow.ellipsis, + ), + ), + ); + } +} + +// ignore: must_be_immutable +class WelcomeMessageEditor extends StatefulWidget { + late SettingsProvider settingsProvider; + + WelcomeMessageEditor(this.settingsProvider, {Key? key}) : super(key: key); + + @override + State createState() => _WelcomeMessageEditorState(); +} + +class _WelcomeMessageEditorState extends State { + final _welcomeMsg = TextEditingController(); + + @override + void initState() { + super.initState(); + _welcomeMsg.text = + widget.settingsProvider.welcomeMessage.replaceAll('%s', '%name%'); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text("edit_welcome_msg".i18n), + content: TextField( + controller: _welcomeMsg, + autofocus: true, + decoration: InputDecoration( + border: const OutlineInputBorder(), + label: Text('welcome_msg'.i18n), + suffixIcon: IconButton( + icon: const Icon(FeatherIcons.x), + onPressed: () { + setState(() { + _welcomeMsg.text = ""; + }); + }, + ), + ), + ), + actions: [ + TextButton( + child: Text( + "cancel".i18n, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + onPressed: () { + Navigator.of(context).maybePop(); + }, + ), + TextButton( + child: Text( + "done".i18n, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + onPressed: () { + // var trimmed = _welcomeMsg.text.trim(); + + // var defLen = trimmed.length; + // var replacedLen = trimmed.replaceAll('%s', '').length; + + // if (defLen - 2 > replacedLen) { + // print('fuck yourself rn'); + // } + var finalText = _welcomeMsg.text + .trim() + .replaceFirst('%name%', '\$s') + .replaceFirst('%user%', '\$s') + .replaceFirst('%username%', '\$s') + .replaceFirst('%me%', '\$s') + .replaceFirst('%profile%', '\$s') + .replaceAll('%', '') + .replaceFirst('\$s', '%s'); + // .replaceAll('\$s', 's'); + + widget.settingsProvider + .update(welcomeMessage: finalText, store: true); + Navigator.of(context).pop(true); + }, + ), + ], + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..7ff72be --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,40 @@ +name: filcnaplo_premium +publish_to: "none" + +environment: + sdk: ">=2.17.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.2 + + # Filcnaplo main dep + filcnaplo: + path: ../filcnaplo/ + filcnaplo_kreta_api: + path: ../filcnaplo_kreta_api/ + filcnaplo_mobile_ui: + path: "../filcnaplo_mobile_ui/" + + provider: ^5.0.0 + flutter_feather_icons: ^2.0.0+1 + uni_links: ^0.5.1 + url_launcher: ^6.1.6 + dropdown_button2: ^1.8.9 + home_widget: ^0.1.6 + image_picker: ^0.8.6 + image_crop: + git: + url: https://github.com/kimaah/image_crop.git + lottie: ^1.4.3 + animations: ^2.0.1 + flutter_svg: ^1.1.6 + flutter_dynamic_icon: ^2.1.0 + android_dynamic_icon: ^1.0.1 + +dev_dependencies: + flutter_lints: ^1.0.0 + +flutter: + uses-material-design: true