From 8f7cae41c265df35cac95e176acb65f52c46ff88 Mon Sep 17 00:00:00 2001 From: TU-Lin Date: Thu, 5 Jan 2023 01:18:22 +0800 Subject: [PATCH] Refactor score page (Only score parts) (#157) * feat: rewrite score tile widget * feat: extract & rewrite the section of course score * feat: add i18n text for key of class and department * refactor: define highlight color for origin divider color * feat: add metrics title widget * feat: add grade metrics cell widget * feat: rewrite semester score section to new metrics style * feat: rewrite two rank sections to new metrics style * refactor: rename widget * fix: incorrect theme divider color use * feat: rewrite warning widget and extract from score page * refactor: use refactored widgets to tidy up the score page, and seperate appbar buttons to two * fix: i10n key typo * refactor: use getter to generate sub widgets while it is not depend on any params for building * refactor: seperate appbar action buttons into isolate widget and wrap buttons with tooltip --- lib/generated/intl/messages_en.dart | 4 +- lib/generated/intl/messages_zh_TW.dart | 4 +- lib/generated/l10n.dart | 24 +- lib/l10n/intl_en.arb | 6 +- lib/l10n/intl_zh_TW.arb | 6 +- lib/src/config/app_colors.dart | 4 +- lib/src/config/app_themes.dart | 8 +- .../coursedetail/screen/course_info_page.dart | 2 +- .../pages/coursetable/course_table_page.dart | 2 +- .../pages/score/app_bar_action_buttons.dart | 38 + lib/ui/pages/score/course_score_section.dart | 44 ++ lib/ui/pages/score/rank_grade_metrics.dart | 64 ++ lib/ui/pages/score/score_page.dart | 685 +++++++----------- .../score/semester_score_grade_metrics.dart | 53 ++ .../widgets/calculation_warning_widget.dart | 22 + .../widgets/grade_metrics_cell_widget.dart | 28 + .../score/widgets/metrics_title_widget.dart | 29 + .../score/widgets/score_tile_widget.dart | 83 +++ 18 files changed, 655 insertions(+), 451 deletions(-) create mode 100644 lib/ui/pages/score/app_bar_action_buttons.dart create mode 100644 lib/ui/pages/score/course_score_section.dart create mode 100644 lib/ui/pages/score/rank_grade_metrics.dart create mode 100644 lib/ui/pages/score/semester_score_grade_metrics.dart create mode 100644 lib/ui/pages/score/widgets/calculation_warning_widget.dart create mode 100644 lib/ui/pages/score/widgets/grade_metrics_cell_widget.dart create mode 100644 lib/ui/pages/score/widgets/metrics_title_widget.dart create mode 100644 lib/ui/pages/score/widgets/score_tile_widget.dart diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index e8a5cced..bbc6ca03 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -157,6 +157,8 @@ class MessageLookup extends MessageLookupByLibrary { "isNewVersion": MessageLookupByLibrary.simpleMessage("Already the latest version"), "isVideo": MessageLookupByLibrary.simpleMessage("Is a video"), "jointElective": MessageLookupByLibrary.simpleMessage("Joint elective"), + "kClass": MessageLookupByLibrary.simpleMessage("class"), + "kDepartment": MessageLookupByLibrary.simpleMessage("department"), "languageSetting": MessageLookupByLibrary.simpleMessage("Language"), "languageSwitch": MessageLookupByLibrary.simpleMessage("Use English interface"), "loadFavorite": MessageLookupByLibrary.simpleMessage("Load favorite"), @@ -232,7 +234,7 @@ class MessageLookup extends MessageLookupByLibrary { "ruleDimension": MessageLookupByLibrary.simpleMessage("Rule of law"), "sameOldPassword": MessageLookupByLibrary.simpleMessage("Same password as before"), "save": MessageLookupByLibrary.simpleMessage("Save"), - "scoreCalculationWarring": MessageLookupByLibrary.simpleMessage( + "scoreCalculationWarning": MessageLookupByLibrary.simpleMessage( "This calculation is for reference only. Actually, please focus on the school."), "scoreSearch": MessageLookupByLibrary.simpleMessage("Score query"), "search": MessageLookupByLibrary.simpleMessage("Search"), diff --git a/lib/generated/intl/messages_zh_TW.dart b/lib/generated/intl/messages_zh_TW.dart index c0797faf..6e2f0f4c 100644 --- a/lib/generated/intl/messages_zh_TW.dart +++ b/lib/generated/intl/messages_zh_TW.dart @@ -153,6 +153,8 @@ class MessageLookup extends MessageLookupByLibrary { "isNewVersion": MessageLookupByLibrary.simpleMessage("已經是最新版本了"), "isVideo": MessageLookupByLibrary.simpleMessage("上課錄影"), "jointElective": MessageLookupByLibrary.simpleMessage("共同選修"), + "kClass": MessageLookupByLibrary.simpleMessage("班級"), + "kDepartment": MessageLookupByLibrary.simpleMessage("系所"), "languageSetting": MessageLookupByLibrary.simpleMessage("語言"), "languageSwitch": MessageLookupByLibrary.simpleMessage("使用英文介面"), "loadFavorite": MessageLookupByLibrary.simpleMessage("載入常用課表"), @@ -224,7 +226,7 @@ class MessageLookup extends MessageLookupByLibrary { "ruleDimension": MessageLookupByLibrary.simpleMessage("法治向度"), "sameOldPassword": MessageLookupByLibrary.simpleMessage("不可以與之前密碼相同"), "save": MessageLookupByLibrary.simpleMessage("儲存"), - "scoreCalculationWarring": MessageLookupByLibrary.simpleMessage("此計算僅供參考,實際請以學校為主"), + "scoreCalculationWarning": MessageLookupByLibrary.simpleMessage("此計算僅供參考,實際請以學校為主"), "scoreSearch": MessageLookupByLibrary.simpleMessage("分數查詢"), "search": MessageLookupByLibrary.simpleMessage("搜尋"), "searchCredit": MessageLookupByLibrary.simpleMessage("查詢學分"), diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 2f82a04c..65d076ea 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -2049,10 +2049,10 @@ class S { } /// `This calculation is for reference only. Actually, please focus on the school.` - String get scoreCalculationWarring { + String get scoreCalculationWarning { return Intl.message( 'This calculation is for reference only. Actually, please focus on the school.', - name: 'scoreCalculationWarring', + name: 'scoreCalculationWarning', desc: '', args: [], ); @@ -2567,6 +2567,26 @@ class S { args: [], ); } + + /// `class` + String get kClass { + return Intl.message( + 'class', + name: 'kClass', + desc: '', + args: [], + ); + } + + /// `department` + String get kDepartment { + return Intl.message( + 'department', + name: 'kDepartment', + desc: '', + args: [], + ); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 0474920f..41e705a0 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -200,7 +200,7 @@ "takeSelect": "Take select", "takeForeignDepartmentCredits": "Foreign Department Credits", "takeForeignDepartmentCreditsLimit": "Credit limit", - "scoreCalculationWarring": "This calculation is for reference only. Actually, please focus on the school.", + "scoreCalculationWarning": "This calculation is for reference only. Actually, please focus on the school.", "resultsOfVariousSubjects": "Results of various subjects", "semesterGrades": "Semester grades", "totalAverage": "Total average", @@ -251,5 +251,7 @@ "zuvioAutoRollCallFeatureReleaseNotice": "Zuvio's (auto) roll-call reminder is coming soon!", "comingSoon": "Coming Soon!", "androidPrivateBrowseGuideTitle": "About Incognito Browse", - "androidPrivateBrowseGuideSubTitle": "Open Incognito browsing to enhanced security" + "androidPrivateBrowseGuideSubTitle": "Open Incognito browsing to enhanced security", + "kClass": "class", + "kDepartment": "department" } \ No newline at end of file diff --git a/lib/l10n/intl_zh_TW.arb b/lib/l10n/intl_zh_TW.arb index 8f27f994..9f107d24 100644 --- a/lib/l10n/intl_zh_TW.arb +++ b/lib/l10n/intl_zh_TW.arb @@ -200,7 +200,7 @@ "takeSelect": "實得選修", "takeForeignDepartmentCredits": "外系學分", "takeForeignDepartmentCreditsLimit": "學分上限", - "scoreCalculationWarring": "此計算僅供參考,實際請以學校為主", + "scoreCalculationWarning": "此計算僅供參考,實際請以學校為主", "resultsOfVariousSubjects": "各科成績", "semesterGrades": "學期成績", "totalAverage": "總平均", @@ -251,5 +251,7 @@ "zuvioAutoRollCallFeatureReleaseNotice": "Zuvio (自動)點名提醒的功能即將上線\n敬請期待!", "comingSoon": "即將上線!", "androidPrivateBrowseGuideTitle": "關於隱私瀏覽", - "androidPrivateBrowseGuideSubTitle": "開啟隱私瀏覽,安全更有保障" + "androidPrivateBrowseGuideSubTitle": "開啟隱私瀏覽,安全更有保障", + "kClass": "班級", + "kDepartment": "系所" } \ No newline at end of file diff --git a/lib/src/config/app_colors.dart b/lib/src/config/app_colors.dart index 1c819d88..b28d7638 100644 --- a/lib/src/config/app_colors.dart +++ b/lib/src/config/app_colors.dart @@ -1,5 +1,3 @@ -// TODO: remove sdk version selector after migrating to null-safety. -// @dart=2.10 import 'package:flutter/material.dart'; class AppColors { @@ -9,7 +7,7 @@ class AppColors { // font color static const Color darkFontColor = Colors.black87; static const Color lightFontColor = Colors.white; - static final Color greyFontColor = Colors.grey[700]; + static final Color greyFontColor = Colors.grey[700]!; //Colors for theme static Color lightPrimary = Colors.white; diff --git a/lib/src/config/app_themes.dart b/lib/src/config/app_themes.dart index d5a2fb38..13cfde43 100644 --- a/lib/src/config/app_themes.dart +++ b/lib/src/config/app_themes.dart @@ -1,5 +1,3 @@ -// TODO: remove sdk version selector after migrating to null-safety. -// @dart=2.10 import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_app/src/config/app_colors.dart'; @@ -15,7 +13,8 @@ class AppThemes { appBarTheme: const AppBarTheme( color: Colors.blueAccent, ), - dividerColor: const Color(0xFFF8F8F8), + dividerColor: const Color(0xFF2F2F2F), + highlightColor: const Color(0xFFF8F8F8), scaffoldBackgroundColor: AppColors.lightBG, cupertinoOverrideTheme: const CupertinoThemeData( primaryColor: AppColors.mainColor, @@ -32,7 +31,8 @@ class AppThemes { primaryColor: AppColors.darkPrimary, scaffoldBackgroundColor: AppColors.darkBG, toggleableActiveColor: Colors.blueAccent, - dividerColor: const Color(0xFF2F2F2F), + dividerColor: const Color(0xFFF8F8F8), + highlightColor: const Color(0xFF2F2F2F), appBarTheme: const AppBarTheme( color: Colors.black26, ), diff --git a/lib/ui/pages/coursedetail/screen/course_info_page.dart b/lib/ui/pages/coursedetail/screen/course_info_page.dart index 9c5483c4..91fd8e48 100644 --- a/lib/ui/pages/coursedetail/screen/course_info_page.dart +++ b/lib/ui/pages/coursedetail/screen/course_info_page.dart @@ -278,7 +278,7 @@ class _CourseInfoPageState extends State with AutomaticKeepAlive } Widget _buildClassmateInfo(int index, ClassmateJson classmate) { - final color = (index % 2 == 1) ? Theme.of(context).backgroundColor : Theme.of(context).dividerColor; + final color = (index % 2 == 1) ? Theme.of(context).backgroundColor : Theme.of(context).highlightColor; return Container( decoration: BoxDecoration( color: color, diff --git a/lib/ui/pages/coursetable/course_table_page.dart b/lib/ui/pages/coursetable/course_table_page.dart index c6737386..745aa174 100644 --- a/lib/ui/pages/coursetable/course_table_page.dart +++ b/lib/ui/pages/coursetable/course_table_page.dart @@ -543,7 +543,7 @@ class _CourseTablePageState extends State { Widget _buildCourseTable(int index) { final section = courseTableControl.getSectionIntList[index]; - final color = ((index % 2 == 1) ? Theme.of(context).backgroundColor : Theme.of(context).dividerColor) + final color = ((index % 2 == 1) ? Theme.of(context).backgroundColor : Theme.of(context).highlightColor) .withAlpha(courseTableWithAlpha); final List widgetList = []; widgetList.add( diff --git a/lib/ui/pages/score/app_bar_action_buttons.dart b/lib/ui/pages/score/app_bar_action_buttons.dart new file mode 100644 index 00000000..243a8452 --- /dev/null +++ b/lib/ui/pages/score/app_bar_action_buttons.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_app/src/r.dart'; + +class ScorePageAppBarActionButtons extends StatelessWidget { + const ScorePageAppBarActionButtons({ + super.key, + required VoidCallback onRefreshPressed, + required VoidCallback onCalculateCreditPressed, + }) : _onRefreshPressed = onRefreshPressed, + _onCalculateCreditPressed = onCalculateCreditPressed; + + final VoidCallback _onRefreshPressed; + final VoidCallback _onCalculateCreditPressed; + + Widget get _refreshButton => Tooltip( + message: R.current.refresh, + child: IconButton( + icon: const Icon(Icons.refresh), + onPressed: _onRefreshPressed, + ), + ); + + Widget get _calculateCreditButton => Tooltip( + message: R.current.calculationCredit, + child: IconButton( + icon: const Icon(Icons.calculate), + onPressed: _onCalculateCreditPressed, + ), + ); + + @override + Widget build(BuildContext context) => Row( + children: [ + _refreshButton, + _calculateCreditButton, + ], + ); +} diff --git a/lib/ui/pages/score/course_score_section.dart b/lib/ui/pages/score/course_score_section.dart new file mode 100644 index 00000000..8116f372 --- /dev/null +++ b/lib/ui/pages/score/course_score_section.dart @@ -0,0 +1,44 @@ +// ignore_for_file: import_of_legacy_library_into_null_safe + +import 'package:flutter/material.dart'; +import 'package:flutter_app/src/model/course/course_score_json.dart'; +import 'package:flutter_app/src/r.dart'; +import 'package:flutter_app/ui/pages/score/widgets/score_tile_widget.dart'; +import 'package:flutter_app/ui/pages/score/widgets/metrics_title_widget.dart'; + +class CourseScoreSection extends StatelessWidget { + const CourseScoreSection({ + super.key, + required List scoreInfoList, + }) : _scoreInfoList = scoreInfoList; + + final List _scoreInfoList; + + void _onCategoryChanged(int? category) { + // TODO(TU): implement this method in view model or controller. + } + + @override + Widget build(BuildContext context) => Column( + children: [ + MetricsTitle(title: R.current.resultsOfVariousSubjects), + ListView.builder( + shrinkWrap: true, + itemCount: _scoreInfoList.length, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (_, index) { + final scoreInfo = _scoreInfoList[index]; + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: ScoreTile( + courseName: scoreInfo.name, + category: scoreInfo.category, + scoreValue: scoreInfo.score, + onCategoryChanged: _onCategoryChanged, + ), + ); + }, + ), + ], + ); +} diff --git a/lib/ui/pages/score/rank_grade_metrics.dart b/lib/ui/pages/score/rank_grade_metrics.dart new file mode 100644 index 00000000..073cc482 --- /dev/null +++ b/lib/ui/pages/score/rank_grade_metrics.dart @@ -0,0 +1,64 @@ +// ignore_for_file: import_of_legacy_library_into_null_safe + +import 'package:flutter/material.dart'; +import 'package:flutter_app/src/model/course/course_score_json.dart'; +import 'package:flutter_app/src/r.dart'; +import 'package:flutter_app/ui/pages/score/widgets/grade_metrics_cell_widget.dart'; +import 'package:flutter_app/ui/pages/score/widgets/metrics_title_widget.dart'; + +class RankGradeMetrics extends StatelessWidget { + const RankGradeMetrics({ + super.key, + required String title, + required RankJson rankInfo, + }) : _title = title, + _rankInfo = rankInfo; + + final String _title; + final RankJson _rankInfo; + + Widget _buildSingleRankMetric(String categoryName, RankItemJson rankInfo) => Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Text( + '($categoryName)', + textAlign: TextAlign.start, + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + GradeMetricsCell( + name: R.current.rank, + value: '${rankInfo.rank.toInt()} / ${rankInfo.total.toInt()}', + ), + GradeMetricsCell( + name: R.current.percentage, + value: '${rankInfo.percentage.toStringAsFixed(1)}%', + ), + ], + ), + ], + ); + + @override + Widget build(BuildContext context) { + final classRankInfo = _rankInfo.course; + final departmentRankInfo = _rankInfo.department; + + return Column( + children: [ + MetricsTitle(title: _title), + _buildSingleRankMetric(R.current.kClass, classRankInfo), + _buildSingleRankMetric(R.current.kDepartment, departmentRankInfo), + ], + ); + } +} diff --git a/lib/ui/pages/score/score_page.dart b/lib/ui/pages/score/score_page.dart index 57521fbc..adb1d709 100644 --- a/lib/ui/pages/score/score_page.dart +++ b/lib/ui/pages/score/score_page.dart @@ -1,10 +1,8 @@ -// TODO: remove sdk version selector after migrating to null-safety. -// @dart=2.10 -import 'package:auto_size_text/auto_size_text.dart'; +// ignore_for_file: import_of_legacy_library_into_null_safe + import 'package:flutter/material.dart'; import 'package:flutter_app/debug/log/log.dart'; import 'package:flutter_app/src/config/app_colors.dart'; -import 'package:flutter_app/src/model/course/course_main_extra_json.dart'; import 'package:flutter_app/src/model/course/course_score_json.dart'; import 'package:flutter_app/src/r.dart'; import 'package:flutter_app/src/store/local_storage.dart'; @@ -14,34 +12,44 @@ import 'package:flutter_app/src/task/task_flow.dart'; import 'package:flutter_app/ui/other/app_expansion_tile.dart'; import 'package:flutter_app/ui/other/my_toast.dart'; import 'package:flutter_app/ui/other/progress_rate_dialog.dart'; +import 'package:flutter_app/ui/pages/score/app_bar_action_buttons.dart'; +import 'package:flutter_app/ui/pages/score/course_score_section.dart'; import 'package:flutter_app/ui/pages/score/graduation_picker.dart'; +import 'package:flutter_app/ui/pages/score/rank_grade_metrics.dart'; +import 'package:flutter_app/ui/pages/score/semester_score_grade_metrics.dart'; +import 'package:flutter_app/ui/pages/score/widgets/calculation_warning_widget.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import 'package:get/get.dart'; import 'package:sprintf/sprintf.dart'; class ScoreViewerPage extends StatefulWidget { - const ScoreViewerPage({Key key}) : super(key: key); + const ScoreViewerPage({super.key}); @override State createState() => _ScoreViewerPageState(); } class _ScoreViewerPageState extends State with TickerProviderStateMixin { - TabController _tabController; - bool isLoading = true; - List courseScoreList = []; - CourseScoreCreditJson courseScoreCredit; + static bool appExpansionInitiallyExpanded = false; + + TabController? _tabController; + late CourseScoreCreditJson courseScoreCredit; + + final List courseScoreList = []; final ScrollController _scrollController = ScrollController(); + final List tabLabelList = []; + final List tabChildList = []; + int _currentTabIndex = 0; - List tabLabelList = []; - List tabChildList = []; - static bool appExpansionInitiallyExpanded = false; + bool isLoading = true; @override void initState() { super.initState(); + courseScoreCredit = LocalStorage.instance.getCourseScoreCredit(); - courseScoreList = LocalStorage.instance.getSemesterCourseScore(); + courseScoreList.addAll(LocalStorage.instance.getSemesterCourseScore()); + if (courseScoreList.isEmpty) { _addScoreRankTask(); } else { @@ -53,60 +61,70 @@ class _ScoreViewerPageState extends State with TickerProviderSt } void _addScoreRankTask() async { - courseScoreList = []; + courseScoreList.clear(); + setState(() { isLoading = true; }); - TaskFlow taskFlow = TaskFlow(); - var scoreTask = ScoreRankTask(); + + final taskFlow = TaskFlow(); + final scoreTask = ScoreRankTask(); taskFlow.addTask(scoreTask); + if (await taskFlow.start()) { - courseScoreList = scoreTask.result; + courseScoreList + ..clear() + ..addAll(scoreTask.result ?? const []); } - if (courseScoreList != null && courseScoreList.isNotEmpty) { + + if (courseScoreList.isNotEmpty) { await LocalStorage.instance.setSemesterCourseScore(courseScoreList); int total = courseScoreCredit.getCourseInfoList().length; - List courseInfoList = courseScoreCredit.getCourseInfoList(); + final courseInfoList = courseScoreCredit.getCourseInfoList(); // ignore: use_build_context_synchronously final progressRateDialog = ProgressRateDialog(context); + progressRateDialog.update(message: R.current.searchingCredit, nowProgress: 0, progressString: "0/0"); progressRateDialog.show(); + for (int i = 0; i < total; i++) { - CourseScoreInfoJson courseInfo = courseInfoList[i]; - String courseId = courseInfo.courseId; + final courseInfo = courseInfoList[i]; + final courseId = courseInfo.courseId; if (courseInfo.category.isEmpty) { - //沒有類別才尋找 - var task = CourseExtraInfoTask(courseId); + final task = CourseExtraInfoTask(courseId); task.openLoadingDialog = false; if (courseId.isNotEmpty) { taskFlow.addTask(task); } } } + total = taskFlow.length; int rate = 0; + taskFlow.callback = (task) { rate++; progressRateDialog.update(nowProgress: rate / total, progressString: sprintf("%d/%d", [rate, total])); - CourseExtraInfoJson extraInfo = task.result; - CourseScoreInfoJson courseScoreInfo = courseScoreCredit.getCourseByCourseId(extraInfo.course.id); + final extraInfo = task.result; + final courseScoreInfo = courseScoreCredit.getCourseByCourseId(extraInfo.course.id); courseScoreInfo.category = extraInfo.course.category; courseScoreInfo.openClass = extraInfo.course.openClass.replaceAll("\n", " "); }; + await taskFlow.start(); await LocalStorage.instance.setSemesterCourseScore(courseScoreList); progressRateDialog.hide(); } else { MyToast.show(R.current.searchCreditIsNullWarning); } - courseScoreList = courseScoreList ?? []; + _buildTabBar(); setState(() { isLoading = false; }); } - void _onSelectFinish(GraduationInformationJson value) { + void _onSelectFinish(GraduationInformationJson? value) { Log.d(value.toString()); if (value != null) { courseScoreCredit.graduationInformation = value; @@ -125,181 +143,147 @@ class _ScoreViewerPageState extends State with TickerProviderSt @override void dispose() { _scrollController.dispose(); - _tabController.dispose(); + _tabController?.dispose(); super.dispose(); } - _onPopupMenuSelect(int value) async { - switch (value) { - case 0: - _addScoreRankTask(); - break; - case 1: - _addSearchCourseTypeTask(); - break; - default: - break; - } - } - @override - Widget build(BuildContext context) { - return DefaultTabController( - length: tabLabelList.length, - child: Scaffold( - appBar: AppBar( - title: Text(R.current.searchScore), - actions: [ - if (courseScoreList.isNotEmpty) - PopupMenuButton( - onSelected: (result) { - setState(() { - _onPopupMenuSelect(result); - }); - }, - itemBuilder: (BuildContext context) => [ - PopupMenuItem( - value: 0, - child: Text(R.current.refresh), - ), - PopupMenuItem( - value: 1, - child: Text(R.current.calculationCredit), - ), - ], - ), - ], - bottom: TabBar( - controller: _tabController, - labelColor: AppColors.mainColor, - unselectedLabelColor: Colors.white, - indicatorSize: TabBarIndicatorSize.label, -// labelPadding: EdgeInsets.symmetric(horizontal: 8), - indicator: const BoxDecoration( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(10), - topRight: Radius.circular(10), + Widget build(BuildContext context) => DefaultTabController( + length: tabLabelList.length, + child: Scaffold( + appBar: AppBar( + title: Text(R.current.searchScore), + actions: [ + if (courseScoreList.isNotEmpty) + ScorePageAppBarActionButtons( + onRefreshPressed: _addScoreRankTask, + onCalculateCreditPressed: _addSearchCourseTypeTask, + ), + ], + bottom: TabBar( + controller: _tabController, + labelColor: AppColors.mainColor, + unselectedLabelColor: Colors.white, + indicatorSize: TabBarIndicatorSize.label, + indicator: const BoxDecoration( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(10), + topRight: Radius.circular(10), + ), + color: Colors.white, ), - color: Colors.white, + isScrollable: true, + tabs: tabLabelList, + onTap: (int index) { + _currentTabIndex = index; + setState(() {}); + }, ), - isScrollable: true, - tabs: tabLabelList, - onTap: (int index) { - _currentTabIndex = index; - setState(() {}); - }, ), - ), - body: SingleChildScrollView( - child: Column( - children: [ - if (!isLoading) (tabChildList.isNotEmpty) ? tabChildList[_currentTabIndex] : const SizedBox.shrink(), - ], + body: SingleChildScrollView( + child: Column( + children: [ + if (!isLoading) (tabChildList.isNotEmpty) ? tabChildList[_currentTabIndex] : const SizedBox.shrink(), + ], + ), ), ), - ), - ); - } + ); void _buildTabBar() { - tabLabelList = []; - tabChildList = []; + tabLabelList.clear(); + tabChildList.clear(); + try { if (courseScoreCredit.graduationInformation.isSelect) { tabLabelList.add(_buildTabLabel(R.current.creditSummary)); - tabChildList.add(StatefulBuilder( - builder: (BuildContext context, void Function(void Function()) setState) { - return AnimationLimiter( - child: Column( - children: AnimationConfiguration.toStaggeredList( - childAnimationBuilder: (widget) => SlideAnimation( - verticalOffset: 50.0, - child: FadeInAnimation(child: widget), - ), - children: [ - _buildSummary(), - _buildGeneralLessonItem(), - _buildOtherDepartmentItem(), - _buildWarning(), - ], + tabChildList.add( + AnimationLimiter( + child: Column( + children: AnimationConfiguration.toStaggeredList( + childAnimationBuilder: (widget) => SlideAnimation( + verticalOffset: 50.0, + child: FadeInAnimation(child: widget), ), + children: [ + _buildSummary(), + _buildGeneralLessonItem(), + _buildOtherDepartmentItem(), + const ScoreCalculationWarning(), + ], ), - ); - }, - )); + ), + ), + ); } } catch (e, stack) { Log.eWithStack(e.toString(), stack); } + for (int i = 0; i < courseScoreList.length; i++) { - SemesterCourseScoreJson courseScore = courseScoreList[i]; + final courseScore = courseScoreList[i]; tabLabelList.add(_buildTabLabel("${courseScore.semester.year}-${courseScore.semester.semester}")); tabChildList.add(_buildSemesterScores(courseScore)); } + if (_tabController != null) { - if (tabChildList.length != _tabController.length) { - _tabController.dispose(); + if (tabChildList.length != _tabController?.length) { + _tabController?.dispose(); _tabController = TabController(length: tabChildList.length, vsync: this); } } else { _tabController = TabController(length: tabChildList.length, vsync: this); } + _currentTabIndex = 0; - _tabController.animateTo(_currentTabIndex); + _tabController?.animateTo(_currentTabIndex); setState(() {}); } - Widget _buildTabLabel(String title) { - return Padding( - padding: const EdgeInsets.only( - left: 12, - right: 12, - ), - child: Tab( - text: title, - ), - ); - } + Widget _buildTabLabel(String title) => Padding( + padding: const EdgeInsets.only( + left: 12, + right: 12, + ), + child: Tab( + text: title, + ), + ); - Widget _buildTile(String title) { - return Container( - padding: const EdgeInsets.only(top: 10, bottom: 10), - child: Material( - //INK可以實現裝飾容器 - child: Ink( - //用ink圓角矩形 - // color: Colors.red, - decoration: BoxDecoration( - //設置四周圓角 角度 - borderRadius: const BorderRadius.all(Radius.circular(25.0)), - //設置四周邊框 - border: Border.all(width: 1, color: Colors.red), - ), - child: InkWell( - //圓角設置,給水波紋也設置同樣的圓角 - //如果這裡不設置就會出現矩形的水波紋效果 - borderRadius: BorderRadius.circular(25.0), - child: Container( - //設置 child 居中 - alignment: const Alignment(0, 0), - height: 50, - width: 300, - child: Text( - title, - textAlign: TextAlign.center, + Widget _buildTile(String title) => Padding( + padding: const EdgeInsets.only(top: 10, bottom: 10), + child: Material( + child: Ink( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(25.0)), + border: Border.all(width: 1, color: Colors.red), + ), + child: InkWell( + borderRadius: BorderRadius.circular(25.0), + child: Container( + alignment: const Alignment(0, 0), + height: 50, + width: 300, + child: Text( + title, + textAlign: TextAlign.center, + ), ), ), ), ), - ), - ); - } + ); Widget _buildSummary() { - List widgetList = []; - GraduationInformationJson graduationInformation = courseScoreCredit.graduationInformation; - Widget widget = _buildTile(sprintf("%s %d/%d", - [R.current.creditSummary, courseScoreCredit.getTotalCourseCredit(), graduationInformation.lowCredit])); + final List widgetList = []; + final graduationInformation = courseScoreCredit.graduationInformation; + + final widget = _buildTile(sprintf("%s %d/%d", [ + R.current.creditSummary, + courseScoreCredit.getTotalCourseCredit(), + graduationInformation.lowCredit, + ])); + widgetList ..add(_buildType(constCourseType[0], R.current.compulsoryCompulsory)) ..add(_buildType(constCourseType[1], R.current.revisedCommonCompulsory)) @@ -307,6 +291,7 @@ class _ScoreViewerPageState extends State with TickerProviderSt ..add(_buildType(constCourseType[3], R.current.compulsoryProfessional)) ..add(_buildType(constCourseType[4], R.current.compulsoryMajorRevision)) ..add(_buildType(constCourseType[5], R.current.professionalElectives)); + return AppExpansionTile( title: widget, initiallyExpanded: appExpansionInitiallyExpanded, @@ -315,32 +300,38 @@ class _ScoreViewerPageState extends State with TickerProviderSt } Widget _buildType(String type, String title) { - int nowCredit = courseScoreCredit.getCreditByType(type); - int minCredit = courseScoreCredit.graduationInformation.courseTypeMinCredit[type]; + final nowCredit = courseScoreCredit.getCreditByType(type); + final minCredit = courseScoreCredit.graduationInformation.courseTypeMinCredit[type]; + return InkWell( - child: Container( + child: Padding( padding: const EdgeInsets.all(5), child: Row( - children: [ + children: [ Expanded( - child: Text(sprintf("%s%s :", [ - type, - title, - ])), + child: Text( + sprintf("%s%s :", [ + type, + title, + ]), + ), ), Text(sprintf("%d/%d", [nowCredit, minCredit])) ], ), ), onTap: () { - Map> result = courseScoreCredit.getCourseByType(type); - List courseInfo = []; - for (String key in result.keys.toList()) { + final result = courseScoreCredit.getCourseByType(type); + final List courseInfo = []; + + for (final key in result.keys.toList()) { courseInfo.add(key); - for (CourseScoreInfoJson course in result[key]) { + // FIXME: remove `!`. + for (final course in result[key]!) { courseInfo.add(sprintf(" %s", [course.name])); } } + if (courseInfo.isNotEmpty) { Get.dialog( AlertDialog( @@ -351,7 +342,7 @@ class _ScoreViewerPageState extends State with TickerProviderSt shrinkWrap: true, padding: const EdgeInsets.all(8), itemCount: courseInfo.length, - itemBuilder: (BuildContext context, int index) { + itemBuilder: (_, index) { return SizedBox( height: 35, child: Text(courseInfo[index]), @@ -375,39 +366,46 @@ class _ScoreViewerPageState extends State with TickerProviderSt ); } - Widget _buildOneLineCourse(String name, String openClass) { - return Container( - padding: const EdgeInsets.all(5), - child: Row( - children: [ - Expanded( - child: Text(name), - ), - Text(openClass) - ], - ), - ); - } + Widget _buildOneLineCourse(String name, String openClass) => Padding( + padding: const EdgeInsets.all(5), + child: Row( + children: [ + Expanded( + child: Text(name), + ), + Text(openClass) + ], + ), + ); Widget _buildGeneralLessonItem() { - Map> generalLesson = courseScoreCredit.getGeneralLesson(); - List widgetList = []; + final generalLesson = courseScoreCredit.getGeneralLesson(); + final List widgetList = []; int selectCredit = 0; int coreCredit = 0; - for (String key in generalLesson.keys) { - for (CourseScoreInfoJson course in generalLesson[key]) { + + for (final key in generalLesson.keys) { + // FIXME: remove `!`. + for (final course in generalLesson[key]!) { if (course.isCoreGeneralLesson) { coreCredit += course.credit.toInt(); } else { selectCredit += course.credit.toInt(); } - Widget courseItemWidget; - courseItemWidget = _buildOneLineCourse(course.name, course.openClass); + + final courseItemWidget = _buildOneLineCourse(course.name, course.openClass); widgetList.add(courseItemWidget); } } - Widget titleWidget = _buildTile(sprintf("%s \n %s:%d %s:%d", - [R.current.generalLessonSummary, R.current.takeCore, coreCredit, R.current.takeSelect, selectCredit])); + + final titleWidget = _buildTile(sprintf("%s \n %s:%d %s:%d", [ + R.current.generalLessonSummary, + R.current.takeCore, + coreCredit, + R.current.takeSelect, + selectCredit, + ])); + return AppExpansionTile( title: titleWidget, initiallyExpanded: appExpansionInitiallyExpanded, @@ -416,31 +414,29 @@ class _ScoreViewerPageState extends State with TickerProviderSt } Widget _buildOtherDepartmentItem() { - String department = LocalStorage.instance.getGraduationInformation().selectDepartment; - int otherDepartmentMaxCredit = courseScoreCredit.graduationInformation.outerDepartmentMaxCredit; - try { - department = department.substring(0, 2); - Log.d(department); - } catch (e) { - 0; - } + final department = LocalStorage.instance.getGraduationInformation().selectDepartment.substring(0, 2); + final otherDepartmentMaxCredit = courseScoreCredit.graduationInformation.outerDepartmentMaxCredit; + Map> generalLesson = courseScoreCredit.getOtherDepartmentCourse(department); - List widgetList = []; + final List widgetList = []; int otherDepartmentCredit = 0; - for (String key in generalLesson.keys) { - for (CourseScoreInfoJson course in generalLesson[key]) { + + for (final key in generalLesson.keys) { + // FIXME: remove `!`. + for (final course in generalLesson[key]!) { otherDepartmentCredit += course.credit.toInt(); - Widget courseItemWidget; - courseItemWidget = courseItemWidget = _buildOneLineCourse(course.name, course.openClass); + final courseItemWidget = _buildOneLineCourse(course.name, course.openClass); widgetList.add(courseItemWidget); } } - Widget titleWidget = _buildTile(sprintf("%s: %d %s: %d", [ + + final titleWidget = _buildTile(sprintf("%s: %d %s: %d", [ R.current.takeForeignDepartmentCredits, otherDepartmentCredit, R.current.takeForeignDepartmentCreditsLimit, otherDepartmentMaxCredit ])); + return AppExpansionTile( title: titleWidget, initiallyExpanded: appExpansionInitiallyExpanded, @@ -448,238 +444,59 @@ class _ScoreViewerPageState extends State with TickerProviderSt ); } - Widget _buildWarning() { - return Container( - padding: const EdgeInsets.all(5), - child: Row( - children: [ - Expanded( - child: Text( - R.current.scoreCalculationWarring, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - textAlign: TextAlign.center, - ), - ), - ], - ), - ); - } - - Widget _buildSemesterScores(SemesterCourseScoreJson courseScore) { - return Container( - padding: const EdgeInsets.all(24.0), - child: AnimationLimiter( - child: Column( - children: AnimationConfiguration.toStaggeredList( - childAnimationBuilder: (widget) => SlideAnimation( - verticalOffset: 50.0, - child: FadeInAnimation( - child: widget, + Widget _buildSemesterScores(SemesterCourseScoreJson courseScore) => Padding( + padding: const EdgeInsets.all(24.0), + child: AnimationLimiter( + child: Column( + children: AnimationConfiguration.toStaggeredList( + childAnimationBuilder: (widget) => SlideAnimation( + verticalOffset: 50.0, + child: FadeInAnimation( + child: widget, + ), ), + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: CourseScoreSection(scoreInfoList: courseScore.courseScoreList), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: SemesterScoreGradeMetrics( + totalAverageScoreValue: courseScore.getAverageScoreString(), + performanceScoreValue: courseScore.getPerformanceScoreString(), + totalCreditValue: courseScore.getTotalCreditString(), + creditsEarnedValue: courseScore.getTakeCreditString(), + ), + ), + _buildRankMetrics(courseScore), + ], ), - children: [ - ..._buildCourseScores(courseScore), - const SizedBox(height: 16), - ..._buildSemesterScore(courseScore), - const SizedBox(height: 16), - ..._buildRanks(courseScore), - const SizedBox(height: 16), - ], ), ), - ), - ); - } - - List _buildCourseScores(SemesterCourseScoreJson courseScore) { - List scoreList = courseScore.courseScoreList; - return [ - _buildTitle(R.current.resultsOfVariousSubjects), - for (CourseScoreInfoJson score in scoreList) _buildScoreItem(score), - ]; - } - - Widget _buildScoreItem(CourseScoreInfoJson score) { - return StatefulBuilder(builder: (BuildContext context, void Function(void Function()) setState) { - int typeSelect = constCourseType.indexOf(score.category); - return Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: AutoSizeText( - score.name, - style: const TextStyle(fontSize: 16.0), - ), - ), - if (score.category.isNotEmpty) - DropdownButton( - underline: Container(), - value: typeSelect, - items: constCourseType - .map((e) => DropdownMenuItem( - value: constCourseType.indexOf(e), - child: Text( - e, - style: const TextStyle(fontSize: 16), - ), - )) - .toList(), - onChanged: (value) { - setState(() { - typeSelect = value; - score.category = constCourseType[typeSelect]; - /* - print(courseScoreList - .map((e) => e.courseScoreList - .map((k) => k.category) - .toList()) - .toList()); - */ - //存檔 - LocalStorage.instance.setCourseScoreCredit(courseScoreCredit); - LocalStorage.instance.saveCourseScoreCredit(); - }); - }), - SizedBox( - width: 40, - child: Text(score.score, style: const TextStyle(fontSize: 16.0), textAlign: TextAlign.end), - ), - ], - ), - const SizedBox( - height: 8.0, - ), - ], ); - }); - } - List _buildSemesterScore(SemesterCourseScoreJson courseScore) { - return [ - _buildTitle(R.current.semesterGrades), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: AutoSizeText( - sprintf("%s: %s", [R.current.totalAverage, courseScore.getAverageScoreString()]), - style: const TextStyle(fontSize: 16), - maxLines: 1, - ), - ), - Expanded( - child: AutoSizeText( - sprintf("%s: %s", [R.current.performanceScores, courseScore.getPerformanceScoreString()]), - textAlign: TextAlign.end, - style: const TextStyle(fontSize: 16), - maxLines: 1, - ), - ), - ], - ), - const SizedBox( - height: 8, - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: AutoSizeText( - sprintf( - "%s: %s", - [R.current.practiceCredit, courseScore.getTotalCreditString()], + Widget _buildRankMetrics(SemesterCourseScoreJson courseScore) => (courseScore.isRankEmpty) + ? Text( + R.current.noRankInfo, + style: const TextStyle(fontSize: 24), + ) + : Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: RankGradeMetrics( + title: R.current.semesterRanking, + rankInfo: courseScore.now, ), - style: const TextStyle(fontSize: 16), - maxLines: 1, - ), - ), - Expanded( - child: AutoSizeText( - sprintf("%s: %s", [R.current.creditsEarned, courseScore.getTakeCreditString()]), - textAlign: TextAlign.end, - style: const TextStyle(fontSize: 16), - maxLines: 1, - ), - ), - ], - ), - const SizedBox( - height: 8, - ), - ]; - } - - List _buildRanks(SemesterCourseScoreJson courseScore) { - return (courseScore.isRankEmpty) - ? [ - Text( - R.current.noRankInfo, - style: const TextStyle(fontSize: 24), - ) - ] - : [ - _buildRankItems(courseScore.now, R.current.semesterRanking), - const SizedBox( - height: 16, ), - _buildRankItems(courseScore.history, R.current.previousRankings), - ]; - } - - Widget _buildRankItems(RankJson rank, String title) { - double fontSize = 16; - TextStyle textStyle = TextStyle(fontSize: fontSize); - return Column( - children: [ - _buildTitle(title), - _buildRankPart(rank.course, textStyle), - _buildRankPart(rank.department, textStyle), - const SizedBox( - height: 8, - ), - ], - ); - } - - Widget _buildRankPart(RankItemJson rankItem, [TextStyle textStyle]) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: AutoSizeText( - sprintf( - "%s: %s %s: %s %s: %s% ", - [ - R.current.rank, - rankItem.rank.toString(), - R.current.totalPeople, - rankItem.total.toString(), - R.current.percentage, - rankItem.percentage.toString(), - ], + Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: RankGradeMetrics( + title: R.current.previousRankings, + rankInfo: courseScore.history, + ), ), - style: textStyle, - minFontSize: 6, - maxLines: 1, - textAlign: TextAlign.center, - ), - ), - ], - ); - } - - Widget _buildTitle(String text) { - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Text( - text, - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - ), - ), - ); - } + ], + ); } diff --git a/lib/ui/pages/score/semester_score_grade_metrics.dart b/lib/ui/pages/score/semester_score_grade_metrics.dart new file mode 100644 index 00000000..a1e0921b --- /dev/null +++ b/lib/ui/pages/score/semester_score_grade_metrics.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_app/src/r.dart'; +import 'package:flutter_app/ui/pages/score/widgets/grade_metrics_cell_widget.dart'; +import 'package:flutter_app/ui/pages/score/widgets/metrics_title_widget.dart'; + +class SemesterScoreGradeMetrics extends StatelessWidget { + const SemesterScoreGradeMetrics({ + super.key, + required String totalAverageScoreValue, + required String performanceScoreValue, + required String totalCreditValue, + required String creditsEarnedValue, + }) : _totalAverageScoreValue = totalAverageScoreValue, + _performanceScoreValue = performanceScoreValue, + _totalCreditValue = totalCreditValue, + _creditsEarnedValue = creditsEarnedValue; + + final String _totalAverageScoreValue; + final String _performanceScoreValue; + final String _totalCreditValue; + final String _creditsEarnedValue; + + @override + Widget build(BuildContext context) => Column( + children: [ + MetricsTitle(title: R.current.semesterGrades), + GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 2, + childAspectRatio: 3, + children: [ + GradeMetricsCell( + name: R.current.totalAverage, + value: _totalAverageScoreValue, + ), + GradeMetricsCell( + name: R.current.performanceScores, + value: _performanceScoreValue, + ), + GradeMetricsCell( + name: R.current.practiceCredit, + value: _totalCreditValue, + ), + GradeMetricsCell( + name: R.current.creditsEarned, + value: _creditsEarnedValue, + ), + ], + ), + ], + ); +} diff --git a/lib/ui/pages/score/widgets/calculation_warning_widget.dart b/lib/ui/pages/score/widgets/calculation_warning_widget.dart new file mode 100644 index 00000000..5fa9ce95 --- /dev/null +++ b/lib/ui/pages/score/widgets/calculation_warning_widget.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_app/src/r.dart'; + +class ScoreCalculationWarning extends StatelessWidget { + const ScoreCalculationWarning({super.key}); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.all(5), + child: Row( + children: [ + Expanded( + child: Text( + R.current.scoreCalculationWarning, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + ), + ], + ), + ); +} diff --git a/lib/ui/pages/score/widgets/grade_metrics_cell_widget.dart b/lib/ui/pages/score/widgets/grade_metrics_cell_widget.dart new file mode 100644 index 00000000..fd1a22e2 --- /dev/null +++ b/lib/ui/pages/score/widgets/grade_metrics_cell_widget.dart @@ -0,0 +1,28 @@ +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:flutter/material.dart'; + +class GradeMetricsCell extends StatelessWidget { + const GradeMetricsCell({ + super.key, + required String name, + required String value, + }) : _name = name, + _value = value; + + final String _name; + final String _value; + + // TODO: improve the UI of this widget. + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.all(4.0), + child: Center( + child: AutoSizeText( + '$_name: $_value', + style: const TextStyle(fontSize: 16), + maxLines: 1, + textAlign: TextAlign.center, + ), + ), + ); +} diff --git a/lib/ui/pages/score/widgets/metrics_title_widget.dart b/lib/ui/pages/score/widgets/metrics_title_widget.dart new file mode 100644 index 00000000..24c971cb --- /dev/null +++ b/lib/ui/pages/score/widgets/metrics_title_widget.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +class MetricsTitle extends StatelessWidget { + const MetricsTitle({ + super.key, + required String title, + }) : _title = title; + + final String _title; + + @override + Widget build(BuildContext context) => Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + _title, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + ), + Divider( + color: Theme.of(context).dividerColor, + ), + ], + ); +} diff --git a/lib/ui/pages/score/widgets/score_tile_widget.dart b/lib/ui/pages/score/widgets/score_tile_widget.dart new file mode 100644 index 00000000..2593deb5 --- /dev/null +++ b/lib/ui/pages/score/widgets/score_tile_widget.dart @@ -0,0 +1,83 @@ +// ignore_for_file: import_of_legacy_library_into_null_safe + +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_app/src/model/course/course_score_json.dart'; + +typedef OnCategoryChanged = void Function(int? category); + +class ScoreTile extends StatelessWidget { + ScoreTile({ + super.key, + required String courseName, + required String category, + required String scoreValue, + OnCategoryChanged? onCategoryChanged, + }) : _courseName = courseName, + _category = category, + _scoreValue = scoreValue, + _onCategoryChanged = onCategoryChanged; + + /// The score value of a course. + /// Note that we should make the score's type to be a [String] instead of [int] since the score can be a string like "Q". + final String _scoreValue; + final String _category; + final String _courseName; + final OnCategoryChanged? _onCategoryChanged; + + final ValueNotifier _selectedCategory = ValueNotifier(null); + + int? _getInitialCategoryIndex() { + final index = constCourseType.indexOf(_category); + return index == -1 ? null : index; + } + + Widget get _courseNameText => AutoSizeText( + _courseName, + style: const TextStyle(fontSize: 16.0), + ); + + Widget get _categoryMenu => ValueListenableBuilder( + valueListenable: _selectedCategory, + builder: (_, value, __) => DropdownButton( + underline: const SizedBox.shrink(), + value: value ?? _getInitialCategoryIndex(), + items: constCourseType + .asMap() + .entries + .map((category) => _buildCategoryMenuItem(category.value, category.key)) + .toList(), + onChanged: (newCategory) { + _selectedCategory.value = newCategory; + _onCategoryChanged?.call(newCategory); + }, + ), + ); + + Widget get _scoreValueText => SizedBox( + width: 40, + child: Text( + _scoreValue, + style: const TextStyle(fontSize: 16.0), + textAlign: TextAlign.end, + ), + ); + + DropdownMenuItem _buildCategoryMenuItem(String category, int index) => DropdownMenuItem( + value: index, + child: Text( + category, + style: const TextStyle(fontSize: 16.0), + ), + ); + + @override + Widget build(BuildContext context) => Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded(child: _courseNameText), + if (_category.isNotEmpty) _categoryMenu, + _scoreValueText, + ], + ); +}