From 215e3d2f89d6ed6fbe73b0b14fd39c01015c94d1 Mon Sep 17 00:00:00 2001 From: Jerry Li <39736661+thermitex@users.noreply.github.com> Date: Fri, 21 Jun 2024 14:21:20 +0800 Subject: [PATCH] Add course grade animation and course custom color (#16) --- README.md | 4 + lib/src/common/services/constants.dart | 2 + lib/src/common/services/moodle.dart | 13 +-- lib/src/common/services/moodle_extra.dart | 17 +++- lib/src/common/services/moodle_managers.dart | 8 ++ lib/src/common/services/reminders.dart | 2 +- lib/src/common/ui/toast.dart | 9 +- lib/src/routes/courses/course_detail.dart | 15 ++- .../routes/courses/course_detail_grade.dart | 38 ++++++-- .../routes/courses/courses_collection.dart | 95 +++++++++++++++++-- lib/src/routes/events/create/create.dart | 4 +- lib/src/routes/events/event_detail.dart | 2 +- lib/src/routes/events/events_list.dart | 2 +- .../events/reminders/reminder_detail.dart | 4 +- lib/src/routes/settings/settings.dart | 2 +- pubspec.lock | 24 +++++ pubspec.yaml | 2 + 17 files changed, 202 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 8b5a8b8..e239366 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ ![Flutter Build](https://github.com/thermitex/cuckoo-flutter/actions/workflows/flutter-build.yml/badge.svg) [![License](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE) +[![Download iOS](https://img.shields.io/badge/Download-iOS-lightgrey.svg)](https://apps.apple.com/us/app/cuckoo-hku-moodle-client/id1550121476) +[![Download Android](https://img.shields.io/badge/Download-Android-green.svg)](https://github.com/thermitex/cuckoo-flutter/releases/latest) +[![Invite](https://dcbadge.limes.pink/api/server/Z7KWUuEyyG?style=flat)](https://discord.gg/Z7KWUuEyyG) +[![Github Stars](https://img.shields.io/github/stars/thermitex/cuckoo-flutter?style=social)](https://github.com/thermitex/cuckoo-flutter/stargazers) Cuckoo is a mobile [Moodle](https://moodle.org) client majorly designed for [HKU Moodle](https://moodle.hku.hk). Written in Flutter, Cuckoo has a better performance compared with the official Moodle app and integrates useful features such as customizable reminder rules and workload estimation. Cuckoo can be easily modified to support **other** Moodle platforms as well. diff --git a/lib/src/common/services/constants.dart b/lib/src/common/services/constants.dart index ba56105..953eddb 100644 --- a/lib/src/common/services/constants.dart +++ b/lib/src/common/services/constants.dart @@ -192,4 +192,6 @@ class Constants { 'We strive to constantly improve Cuckoo for a better user experience. Don\'t forget to join the Discord community to let us know how we can further improve.'; static const kNotiPermissionWarning = 'Cuckoo currently does not have the permission to display notifications, and therfore the reminders here may not work as expected. Please go to Settings and allow notifications from Cuckoo.'; + static const kColorPanelChooseColor = 'Choose Custom Color'; + static const kColorPanelResetColor = 'Reset Custom Color'; } diff --git a/lib/src/common/services/moodle.dart b/lib/src/common/services/moodle.dart index 1a82f58..2b8b06b 100644 --- a/lib/src/common/services/moodle.dart +++ b/lib/src/common/services/moodle.dart @@ -14,6 +14,7 @@ import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; import 'package:html/parser.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:table_calendar/table_calendar.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -514,18 +515,6 @@ class Moodle { return Future.wait(requests).then((_) => moodle._saveEvents()); } - /// Get the course info associated with the event. - /// - /// Recommend to use the shortcut `event.course` instead. - static MoodleCourse? courseForEvent(MoodleEvent event) { - final moodle = Moodle(); - final courseId = event.courseid; - if (courseId != null) { - return moodle.courseManager._courseMap[courseId]; - } - return null; - } - /// Add or update a custom event to the current event list. /// /// Custom events will maintain in the events list after merging. diff --git a/lib/src/common/services/moodle_extra.dart b/lib/src/common/services/moodle_extra.dart index 33efeb1..99021af 100644 --- a/lib/src/common/services/moodle_extra.dart +++ b/lib/src/common/services/moodle_extra.dart @@ -126,7 +126,7 @@ class MoodleFunctionResponse { /// Shortcuts for Moodle event. extension MoodleEventExtension on MoodleEvent { /// Course for Moodle event. - MoodleCourse? get course => Moodle.courseForEvent(this); + MoodleCourse? get course => Moodle().courseManager._courseForEvent(this); /// Remaining seconds for Moodle event. num get remainingTime => timestart - DateTime.now().secondEpoch; @@ -151,6 +151,14 @@ extension MoodleEventExtension on MoodleEvent { /// Event associated color. Color? get color => course?.color; + /// Event associated color that is subscribed to change notifiers. + /// + /// Note that this will add extra building times for the widget, therefore + /// use color getter as much as possible. + Color? contextWatchedColor(BuildContext context) => + context.select( + (manager) => manager._courseForEvent(this)?.color); + /// If the event has expired. bool get expired => remainingTime < 0; @@ -189,6 +197,13 @@ extension MoodleCourseExtension on MoodleCourse { Moodle()._saveCourses(); } + /// Set the custom color of the course. + set customColor(Color? color) { + colorHex = color?.toHex(); + Moodle().courseManager._notifyManually(); + Moodle()._saveCourses(); + } + /// Mark the access to the course. void markAccess() { lastaccess = DateTime.now().secondEpoch; diff --git a/lib/src/common/services/moodle_managers.dart b/lib/src/common/services/moodle_managers.dart index 390abdf..b554d60 100644 --- a/lib/src/common/services/moodle_managers.dart +++ b/lib/src/common/services/moodle_managers.dart @@ -211,6 +211,14 @@ class MoodleCourseManager with ChangeNotifier { return _courses.map((course) => course.id.toString()).toList(); } + /// Get the course info associated with the event. + /// + /// Recommend to use the shortcut `event.course` instead. + MoodleCourse? _courseForEvent(MoodleEvent event) { + final courseId = event.courseid; + return courseId != null ? _courseMap[courseId] : null; + } + /// Manually notify. void _notifyManually({bool flushCache = false}) { if (flushCache) _sortedCoursesCache = {}; diff --git a/lib/src/common/services/reminders.dart b/lib/src/common/services/reminders.dart index b427c01..7bbf666 100644 --- a/lib/src/common/services/reminders.dart +++ b/lib/src/common/services/reminders.dart @@ -229,7 +229,7 @@ class Reminders with ChangeNotifier { static final Reminders _instance = Reminders._internal(); } -extension EventReminderExtenson on EventReminder { +extension EventReminderExtension on EventReminder { /// Timing description to be shown in reminder list. String get timingDescription { final unitDesc = ['second', 'minute', 'hour', 'day', 'week']; diff --git a/lib/src/common/ui/toast.dart b/lib/src/common/ui/toast.dart index 8378e07..d10fad2 100644 --- a/lib/src/common/ui/toast.dart +++ b/lib/src/common/ui/toast.dart @@ -2,6 +2,7 @@ import 'package:cuckoo/src/app.dart'; import 'package:cuckoo/src/common/ui/ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import 'package:fluttertoast/fluttertoast.dart'; /// Show a standard Cuckoo toast at the bottom of the screen. @@ -53,13 +54,13 @@ class CuckooToast { } /// Show the toast. - void show({int delayInMillisec = 0, bool haptic = false}) { - Future.delayed(Duration(milliseconds: delayInMillisec)).then((_) { + void show({Duration? delay, bool haptic = true}) { + Future.delayed(delay ?? 250.ms).then((_) { if (haptic) HapticFeedback.mediumImpact(); _toast.showToast( child: _toastView(), - toastDuration: const Duration(seconds: 2), - fadeDuration: const Duration(milliseconds: 200), + toastDuration: 2.seconds, + fadeDuration: 200.ms, positionedToastBuilder: (context, child) { return Positioned( bottom: 110, diff --git a/lib/src/routes/courses/course_detail.dart b/lib/src/routes/courses/course_detail.dart index 3908747..8192fc3 100644 --- a/lib/src/routes/courses/course_detail.dart +++ b/lib/src/routes/courses/course_detail.dart @@ -7,6 +7,7 @@ import 'package:cuckoo/src/models/index.dart'; import 'package:cuckoo/src/routes/courses/course_detail_grade.dart'; import 'package:cuckoo/src/routes/courses/course_detail_section.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; /// View type of the course. enum CourseViewType { contents, grades } @@ -33,6 +34,10 @@ class _CourseDetailPageState extends State { /// Grades are lazily loaded and not cached. List? _grades; + /// Keep track of a list to see whether the current grade row has been + /// animated. Make sure each animation is played once only. + late List _gradesAnimated; + /// If the current course is marked as favorite. bool _isFavoriteCourse = false; @@ -67,7 +72,7 @@ class _CourseDetailPageState extends State { color: target ? CuckooColors.positivePrimary : CuckooColors.negativePrimary, - )).show(delayInMillisec: 200, haptic: true); + )).show(delay: 200.ms); } CuckooAppBar _pageAppBar() { @@ -228,6 +233,7 @@ class _CourseDetailPageState extends State { Moodle.getCourseGrades(_course).then((grades) { if (grades != null) { _grades = grades; + _gradesAnimated = List.generate(grades.length, (_) => false); setState(() => _contentReady = true); } else { setState(() => _contentError = true); @@ -274,7 +280,12 @@ class _CourseDetailPageState extends State { if (index == _grades!.length + 1) { return const SizedBox(height: 16.0); } - return CourseDetailGradeItem(_course, _grades![index - 1]); + return CourseDetailGradeItem( + _course, + _grades![index - 1], + shouldAnimate: !_gradesAnimated[index - 1], + animationPlayed: () => _gradesAnimated[index - 1] = true, + ); }, separatorBuilder: (context, index) { return const SizedBox(height: 15.0); diff --git a/lib/src/routes/courses/course_detail_grade.dart b/lib/src/routes/courses/course_detail_grade.dart index 0afc1b8..a647bb2 100644 --- a/lib/src/routes/courses/course_detail_grade.dart +++ b/lib/src/routes/courses/course_detail_grade.dart @@ -5,15 +5,21 @@ import 'package:cuckoo/src/common/services/moodle.dart'; import 'package:cuckoo/src/common/ui/ui.dart'; import 'package:cuckoo/src/models/index.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; /// A row for displaying a course grade. class CourseDetailGradeItem extends StatelessWidget { - const CourseDetailGradeItem(this.course, this.grade, {super.key}); + const CourseDetailGradeItem(this.course, this.grade, + {super.key, this.shouldAnimate = true, this.animationPlayed}); final MoodleCourse course; final MoodleCourseGrade grade; + final bool shouldAnimate; + + final void Function()? animationPlayed; + Widget _gradeIndicator(BuildContext context) { double? indicatorValue; if (grade.percentage != null) { @@ -41,13 +47,29 @@ class CourseDetailGradeItem extends StatelessWidget { borderRadius: BorderRadius.circular(15.0), child: Stack(children: [ Container( - color: context.theme.tertiaryBackground, - height: outerSize, - width: outerSize, - child: CustomPaint( - painter: GradeIndicatorPainter( - color: course.color, value: indicatorValue ?? 0), - )), + color: context.theme.tertiaryBackground, + height: outerSize, + width: outerSize, + child: shouldAnimate + ? Animate( + onInit: (_) => animationPlayed?.call(), + ).custom( + duration: 800.ms, + curve: Curves.easeInOutCirc, + begin: 0, + end: indicatorValue ?? 0, + builder: (_, value, __) { + return CustomPaint( + painter: GradeIndicatorPainter( + color: course.color, value: value), + ); + }, + ) + : CustomPaint( + painter: GradeIndicatorPainter( + color: course.color, value: indicatorValue ?? 0), + ), + ), SizedBox( height: outerSize, width: outerSize, diff --git a/lib/src/routes/courses/courses_collection.dart b/lib/src/routes/courses/courses_collection.dart index 08c11d2..0ead462 100644 --- a/lib/src/routes/courses/courses_collection.dart +++ b/lib/src/routes/courses/courses_collection.dart @@ -1,12 +1,16 @@ import 'package:auto_size_text/auto_size_text.dart'; +import 'package:cuckoo/src/app.dart'; import 'package:cuckoo/src/common/extensions/extensions.dart'; import 'package:cuckoo/src/common/services/constants.dart'; import 'package:cuckoo/src/common/services/moodle.dart'; import 'package:cuckoo/src/common/services/settings.dart'; import 'package:cuckoo/src/common/ui/ui.dart'; +import 'package:cuckoo/src/common/widgets/more_panel.dart'; import 'package:cuckoo/src/models/index.dart'; import 'package:cuckoo/src/routes/courses/course_detail.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_colorpicker/flutter_colorpicker.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; @@ -125,11 +129,19 @@ class _MoodleCourseCollectionViewState } } -class MoodleCourseTile extends StatelessWidget { +class MoodleCourseTile extends StatefulWidget { const MoodleCourseTile(this.course, {super.key}); final MoodleCourse course; + @override + State createState() => _MoodleCourseTileState(); +} + +class _MoodleCourseTileState extends State { + // An intermediate variable to store color before applying. + Color? _pickerColor; + /// Icon of the course to show at the background. IconData _courseIcon() { const iconsToUse = [ @@ -154,7 +166,74 @@ class MoodleCourseTile extends StatelessWidget { FontAwesomeIcons.globe, FontAwesomeIcons.seedling, ]; - return iconsToUse[course.id.toInt() % iconsToUse.length]; + return iconsToUse[widget.course.id.toInt() % iconsToUse.length]; + } + + /// Color panel displayed on long tap. + void _showColorEditingPanel(BuildContext context) { + showModalBottomSheet( + context: context, + useRootNavigator: true, + useSafeArea: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(30.0))), + backgroundColor: context.theme.popUpBackground, + builder: (context) { + return MorePanel( + children: [ + MorePanelElement( + title: Constants.kColorPanelChooseColor, + icon: const Icon(Icons.color_lens_rounded), + action: () { + context.navigator.pop(); + Future.delayed(250.ms).then((_) => showDialog( + context: navigatorKey.currentContext!, + builder: (BuildContext context) { + return AlertDialog( + titlePadding: const EdgeInsets.all(0), + contentPadding: const EdgeInsets.all(0), + shape: const RoundedRectangleBorder( + borderRadius: + BorderRadius.all(Radius.circular(30))), + content: SingleChildScrollView( + child: ColorPicker( + pickerColor: widget.course.color, + onColorChanged: (selectedColor) => + _pickerColor = selectedColor, + enableAlpha: false, + labelTypes: const [], + pickerAreaBorderRadius: + const BorderRadius.vertical( + top: Radius.circular(30)), + hexInputBar: false, + displayThumbColor: true, + ), + ), + actions: [ + CuckooButton( + text: Constants.kOK, + action: () { + context.navigator.pop(); + widget.course.customColor = _pickerColor; + }, + ), + ], + ); + }, + )); + }, + ), + MorePanelElement( + title: Constants.kColorPanelResetColor, + icon: const Icon(Icons.replay_rounded), + action: () { + context.navigator.pop(); + widget.course.customColor = null; + }, + ) + ], + ); + }); } @override @@ -163,13 +242,17 @@ class MoodleCourseTile extends StatelessWidget { borderRadius: BorderRadius.circular(20.0), child: GestureDetector( onTap: () => context.platformDependentPush( - builder: (context) => CourseDetailPage(course)), + builder: (context) => CourseDetailPage(widget.course)), + onLongPress: () => _showColorEditingPanel(context), child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [course.color, course.color.withAlpha(220)])), + colors: [ + widget.course.color, + widget.course.color.withAlpha(220) + ])), child: Stack( children: [ Positioned( @@ -196,7 +279,7 @@ class MoodleCourseTile extends StatelessWidget { padding: const EdgeInsets.symmetric( vertical: 7.0, horizontal: 12.0), child: AutoSizeText( - course.courseCode, + widget.course.courseCode, maxLines: 2, overflow: TextOverflow.ellipsis, style: CuckooTextStyles.title( @@ -213,7 +296,7 @@ class MoodleCourseTile extends StatelessWidget { color: context.theme.primaryInverseText .withAlpha(context.isDarkMode ? 140 : 180), child: Text( - course.nameWithoutCode, + widget.course.nameWithoutCode, maxLines: 3, overflow: TextOverflow.ellipsis, style: CuckooTextStyles.body( diff --git a/lib/src/routes/events/create/create.dart b/lib/src/routes/events/create/create.dart index b5aded4..94a64e1 100644 --- a/lib/src/routes/events/create/create.dart +++ b/lib/src/routes/events/create/create.dart @@ -139,7 +139,7 @@ class _CreateEventPageState extends State { icon: const Icon( Icons.delete, color: CuckooColors.negativePrimary, - )).show(delayInMillisec: 250, haptic: true); + )).show(); } }); }, @@ -194,7 +194,7 @@ class _CreateEventPageState extends State { icon: const Icon( Icons.check_circle_rounded, color: CuckooColors.positivePrimary, - )).show(delayInMillisec: 250, haptic: true); + )).show(); } }) ], diff --git a/lib/src/routes/events/event_detail.dart b/lib/src/routes/events/event_detail.dart index a6c3491..ab1bb0b 100644 --- a/lib/src/routes/events/event_detail.dart +++ b/lib/src/routes/events/event_detail.dart @@ -316,7 +316,7 @@ class EventDetailView extends StatelessWidget { ? Icons.unpublished_rounded : Icons.check_circle_rounded, color: CuckooColors.positivePrimary, - )).show(delayInMillisec: 250, haptic: true); + )).show(); } @override diff --git a/lib/src/routes/events/events_list.dart b/lib/src/routes/events/events_list.dart index f2c7fbd..87c5eda 100644 --- a/lib/src/routes/events/events_list.dart +++ b/lib/src/routes/events/events_list.dart @@ -123,7 +123,7 @@ class MoodleEventListTile extends StatelessWidget { if (event.isCompleted && _canShowCompleted(context)) { return context.theme.tertiaryText; } - return event.color ?? context.theme.tertiaryText; + return event.contextWatchedColor(context) ?? context.theme.tertiaryText; } Widget _eventContent(BuildContext context) { diff --git a/lib/src/routes/events/reminders/reminder_detail.dart b/lib/src/routes/events/reminders/reminder_detail.dart index d74877b..6e3540b 100644 --- a/lib/src/routes/events/reminders/reminder_detail.dart +++ b/lib/src/routes/events/reminders/reminder_detail.dart @@ -132,7 +132,7 @@ class _ReminderDetailPageState extends State { icon: const Icon( Icons.delete, color: CuckooColors.negativePrimary, - )).show(delayInMillisec: 250, haptic: true); + )).show(); } }); }, @@ -182,7 +182,7 @@ class _ReminderDetailPageState extends State { icon: const Icon( Icons.check_circle_rounded, color: CuckooColors.positivePrimary, - )).show(delayInMillisec: 250, haptic: true); + )).show(); } }) ], diff --git a/lib/src/routes/settings/settings.dart b/lib/src/routes/settings/settings.dart index 7fc5cef..be75a04 100644 --- a/lib/src/routes/settings/settings.dart +++ b/lib/src/routes/settings/settings.dart @@ -158,7 +158,7 @@ class SettingsPage extends StatelessWidget { icon: const Icon( Icons.check_circle_rounded, color: CuckooColors.positivePrimary, - )).show(delayInMillisec: 250, haptic: true); + )).show(); }, ), ], diff --git a/pubspec.lock b/pubspec.lock index de1a722..a5bd0ec 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -310,6 +310,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_animate: + dependency: "direct main" + description: + name: flutter_animate + sha256: "7c8a6594a9252dad30cc2ef16e33270b6248c4dedc3b3d06c86c4f3f4dc05ae5" + url: "https://pub.dev" + source: hosted + version: "4.5.0" flutter_cache_manager: dependency: transitive description: @@ -318,6 +326,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.2" + flutter_colorpicker: + dependency: "direct main" + description: + name: flutter_colorpicker + sha256: "969de5f6f9e2a570ac660fb7b501551451ea2a1ab9e2097e89475f60e07816ea" + url: "https://pub.dev" + source: hosted + version: "1.1.0" flutter_html: dependency: "direct main" description: @@ -366,6 +382,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.0" + flutter_shaders: + dependency: transitive + description: + name: flutter_shaders + sha256: "02750b545c01ff4d8e9bbe8f27a7731aa3778402506c67daa1de7f5fc3f4befe" + url: "https://pub.dev" + source: hosted + version: "0.1.2" flutter_svg: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 16e254b..65e8534 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,8 @@ dependencies: package_info_plus: ^8.0.0 in_app_purchase: ^3.2.0 device_info_plus: ^10.1.0 + flutter_animate: ^4.5.0 + flutter_colorpicker: ^1.1.0 dev_dependencies: json_model: ^1.0.0