Skip to content

Commit

Permalink
Add course grade animation and course custom color (#16)
Browse files Browse the repository at this point in the history
  • Loading branch information
thermitex authored Jun 21, 2024
1 parent cff3a44 commit 215e3d2
Show file tree
Hide file tree
Showing 17 changed files with 202 additions and 41 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions lib/src/common/services/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
13 changes: 1 addition & 12 deletions lib/src/common/services/moodle.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down
17 changes: 16 additions & 1 deletion lib/src/common/services/moodle_extra.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<MoodleCourseManager, Color?>(
(manager) => manager._courseForEvent(this)?.color);

/// If the event has expired.
bool get expired => remainingTime < 0;

Expand Down Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions lib/src/common/services/moodle_managers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand Down
2 changes: 1 addition & 1 deletion lib/src/common/services/reminders.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down
9 changes: 5 additions & 4 deletions lib/src/common/ui/toast.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 13 additions & 2 deletions lib/src/routes/courses/course_detail.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -33,6 +34,10 @@ class _CourseDetailPageState extends State<CourseDetailPage> {
/// Grades are lazily loaded and not cached.
List<MoodleCourseGrade>? _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<bool> _gradesAnimated;

/// If the current course is marked as favorite.
bool _isFavoriteCourse = false;

Expand Down Expand Up @@ -67,7 +72,7 @@ class _CourseDetailPageState extends State<CourseDetailPage> {
color: target
? CuckooColors.positivePrimary
: CuckooColors.negativePrimary,
)).show(delayInMillisec: 200, haptic: true);
)).show(delay: 200.ms);
}

CuckooAppBar _pageAppBar() {
Expand Down Expand Up @@ -228,6 +233,7 @@ class _CourseDetailPageState extends State<CourseDetailPage> {
Moodle.getCourseGrades(_course).then((grades) {
if (grades != null) {
_grades = grades;
_gradesAnimated = List.generate(grades.length, (_) => false);
setState(() => _contentReady = true);
} else {
setState(() => _contentError = true);
Expand Down Expand Up @@ -274,7 +280,12 @@ class _CourseDetailPageState extends State<CourseDetailPage> {
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);
Expand Down
38 changes: 30 additions & 8 deletions lib/src/routes/courses/course_detail_grade.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
95 changes: 89 additions & 6 deletions lib/src/routes/courses/courses_collection.dart
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<MoodleCourseTile> createState() => _MoodleCourseTileState();
}

class _MoodleCourseTileState extends State<MoodleCourseTile> {
// An intermediate variable to store color before applying.
Color? _pickerColor;

/// Icon of the course to show at the background.
IconData _courseIcon() {
const iconsToUse = [
Expand All @@ -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<void>(
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
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand Down
Loading

0 comments on commit 215e3d2

Please sign in to comment.