From 4b67f7006582f39c8a0a39de7fbac442b5deaa27 Mon Sep 17 00:00:00 2001 From: David Chen Date: Tue, 12 May 2026 10:14:25 +0800 Subject: [PATCH 1/5] feat(ui): add shorten names for semester-picker --- .../lib/src/l10n/strings.g.dart | 4 +- .../lib/src/l10n/strings_en.g.dart | 22 ++++++-- .../lib/src/l10n/strings_en.json | 6 ++- .../lib/src/l10n/strings_zh-Hant-TW.json | 6 ++- .../lib/src/l10n/strings_zh_Hant_TW.g.dart | 12 ++++- .../lib/src/widgets/semester_picker.dart | 51 +++++++++++++++++-- 6 files changed, 88 insertions(+), 13 deletions(-) diff --git a/packages/ap_common_flutter_core/lib/src/l10n/strings.g.dart b/packages/ap_common_flutter_core/lib/src/l10n/strings.g.dart index 57678811..714bc5c9 100644 --- a/packages/ap_common_flutter_core/lib/src/l10n/strings.g.dart +++ b/packages/ap_common_flutter_core/lib/src/l10n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 3 -/// Strings: 1389 (463 per locale) +/// Strings: 1397 (465 per locale) /// -/// Built on 2026-04-10 at 17:56 UTC +/// Built on 2026-05-10 at 19:48 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/packages/ap_common_flutter_core/lib/src/l10n/strings_en.g.dart b/packages/ap_common_flutter_core/lib/src/l10n/strings_en.g.dart index bce72987..5deed278 100644 --- a/packages/ap_common_flutter_core/lib/src/l10n/strings_en.g.dart +++ b/packages/ap_common_flutter_core/lib/src/l10n/strings_en.g.dart @@ -467,8 +467,8 @@ class ApLocalizations with BaseTranslations { /// en: 'Course Info' String get courseInfo => 'Course Info'; - /// en: 'Class Schedule' - String get course => 'Class Schedule'; + /// en: 'Schedule' + String get course => 'Schedule'; /// en: 'Transcript' String get score => 'Transcript'; @@ -1313,6 +1313,18 @@ class ApLocalizations with BaseTranslations { /// en: 'Summer Session' String get summerSession => 'Summer Session'; + /// en: 'Fall' + String get firstSemesterShort => 'Fall'; + + /// en: 'Spring' + String get secondSemesterShort => 'Spring'; + + /// en: 'Winter' + String get winterSessionShort => 'Winter'; + + /// en: 'Summer' + String get summerSessionShort => 'Summer'; + /// en: 'Pre-course' String get preCourse => 'Pre-course'; @@ -1581,7 +1593,7 @@ extension on ApLocalizations { 'announcementEmpty' => 'Oops!There is no latest news. 😋', 'offlineCourse' => 'Offline Class Schedule', 'courseInfo' => 'Course Info', - 'course' => 'Class Schedule', + 'course' => 'Schedule', 'score' => 'Transcript', 'leave' => 'Absence Records System', 'bus' => 'Campus Bus System', @@ -1863,6 +1875,10 @@ extension on ApLocalizations { 'secondSemester' => 'Spring', 'winterSession' => 'Winter Session', 'summerSession' => 'Summer Session', + 'firstSemesterShort' => 'Fall', + 'secondSemesterShort' => 'Spring', + 'winterSessionShort' => 'Winter', + 'summerSessionShort' => 'Summer', 'preCourse' => 'Pre-course', 'summerSessionFirst' => 'Summer Session I', 'summerSessionSpecial' => 'Summer Session (Special)', diff --git a/packages/ap_common_flutter_core/lib/src/l10n/strings_en.json b/packages/ap_common_flutter_core/lib/src/l10n/strings_en.json index 6a3dab5b..0e0992a9 100644 --- a/packages/ap_common_flutter_core/lib/src/l10n/strings_en.json +++ b/packages/ap_common_flutter_core/lib/src/l10n/strings_en.json @@ -141,7 +141,7 @@ "announcementEmpty": "Oops!There is no latest news. 😋", "offlineCourse": "Offline Class Schedule", "courseInfo": "Course Info", - "course": "Class Schedule", + "course": "Schedule", "score": "Transcript", "leave": "Absence Records System", "bus": "Campus Bus System", @@ -423,6 +423,10 @@ "secondSemester": "Spring", "winterSession": "Winter Session", "summerSession": "Summer Session", + "firstSemesterShort": "Fall", + "secondSemesterShort": "Spring", + "winterSessionShort": "Winter", + "summerSessionShort": "Summer", "preCourse": "Pre-course", "summerSessionFirst": "Summer Session I", "summerSessionSpecial": "Summer Session (Special)", diff --git a/packages/ap_common_flutter_core/lib/src/l10n/strings_zh-Hant-TW.json b/packages/ap_common_flutter_core/lib/src/l10n/strings_zh-Hant-TW.json index ac33b0ec..8957dc41 100644 --- a/packages/ap_common_flutter_core/lib/src/l10n/strings_zh-Hant-TW.json +++ b/packages/ap_common_flutter_core/lib/src/l10n/strings_zh-Hant-TW.json @@ -141,7 +141,7 @@ "announcementEmpty": "Oops!沒有任何最新消息 😋", "offlineCourse": "離線課表", "courseInfo": "課程學習", - "course": "學期課表", + "course": "課表", "score": "學期成績", "leave": "缺曠系統", "bus": "校車系統", @@ -423,6 +423,10 @@ "secondSemester": "下學期", "winterSession": "寒修", "summerSession": "暑修", + "firstSemesterShort": "上", + "secondSemesterShort": "下", + "winterSessionShort": "寒", + "summerSessionShort": "暑", "preCourse": "先修", "summerSessionFirst": "暑修(一)", "summerSessionSpecial": "暑修(特)", diff --git a/packages/ap_common_flutter_core/lib/src/l10n/strings_zh_Hant_TW.g.dart b/packages/ap_common_flutter_core/lib/src/l10n/strings_zh_Hant_TW.g.dart index 1c5d2b0d..7786299a 100644 --- a/packages/ap_common_flutter_core/lib/src/l10n/strings_zh_Hant_TW.g.dart +++ b/packages/ap_common_flutter_core/lib/src/l10n/strings_zh_Hant_TW.g.dart @@ -181,7 +181,7 @@ class ApLocalizationsZhHantTw extends ApLocalizations with BaseTranslations 'Oops!沒有任何最新消息 😋'; @override String get offlineCourse => '離線課表'; @override String get courseInfo => '課程學習'; - @override String get course => '學期課表'; + @override String get course => '課表'; @override String get score => '學期成績'; @override String get leave => '缺曠系統'; @override String get bus => '校車系統'; @@ -463,6 +463,10 @@ class ApLocalizationsZhHantTw extends ApLocalizations with BaseTranslations '下學期'; @override String get winterSession => '寒修'; @override String get summerSession => '暑修'; + @override String get firstSemesterShort => '上'; + @override String get secondSemesterShort => '下'; + @override String get winterSessionShort => '寒'; + @override String get summerSessionShort => '暑'; @override String get preCourse => '先修'; @override String get summerSessionFirst => '暑修(一)'; @override String get summerSessionSpecial => '暑修(特)'; @@ -654,7 +658,7 @@ extension on ApLocalizationsZhHantTw { 'announcementEmpty' => 'Oops!沒有任何最新消息 😋', 'offlineCourse' => '離線課表', 'courseInfo' => '課程學習', - 'course' => '學期課表', + 'course' => '課表', 'score' => '學期成績', 'leave' => '缺曠系統', 'bus' => '校車系統', @@ -936,6 +940,10 @@ extension on ApLocalizationsZhHantTw { 'secondSemester' => '下學期', 'winterSession' => '寒修', 'summerSession' => '暑修', + 'firstSemesterShort' => '上', + 'secondSemesterShort' => '下', + 'winterSessionShort' => '寒', + 'summerSessionShort' => '暑', 'preCourse' => '先修', 'summerSessionFirst' => '暑修(一)', 'summerSessionSpecial' => '暑修(特)', diff --git a/packages/ap_common_flutter_ui/lib/src/widgets/semester_picker.dart b/packages/ap_common_flutter_ui/lib/src/widgets/semester_picker.dart index e2f21b56..75bf82ec 100644 --- a/packages/ap_common_flutter_ui/lib/src/widgets/semester_picker.dart +++ b/packages/ap_common_flutter_ui/lib/src/widgets/semester_picker.dart @@ -83,11 +83,13 @@ class SemesterPickerController extends ChangeNotifier { class SemesterUIConfig { const SemesterUIConfig({ this.getName, + this.getShortName, this.getIcon, this.getColor, this.getSortValue, }); final String Function(String value)? getName; + final String Function(String value)? getShortName; final IconData Function(String value)? getIcon; final Color Function(String value, ColorScheme colorScheme)? getColor; final int Function(String value)? getSortValue; @@ -335,8 +337,11 @@ class SemesterPicker extends StatefulWidget { final bool isEmpty = emptySemesters?.contains(semester.code) ?? false; final bool isLoading = loadingSemesters?.contains(semester.code) ?? false; final bool isDisabled = isEmpty || isLoading; - final String semesterName = uiConfig?.getName?.call(semester.value) ?? - SemesterPickerState._getSemesterName(semester.value, context.ap); + final String semesterName = SemesterPickerState._getSemesterName( + semester.value, + context.ap, + uiConfig, + ); final String displayName = semesterName.isNotEmpty ? semesterName : semester.text; @@ -656,8 +661,11 @@ class SemesterPickerState extends State { SemesterUIConfig? uiConfig, [ ApLocalizations? ap, ]) { - final String name = uiConfig?.getName?.call(semester.value) ?? - _getSemesterName(semester.value, ap); + final String name = _getShortSemesterName( + semester.value, + ap, + uiConfig, + ); if (name.isNotEmpty) { return '${semester.year} $name'; } @@ -720,7 +728,12 @@ class SemesterPickerState extends State { static String _getSemesterName( String value, [ ApLocalizations? ap, + SemesterUIConfig? uiConfig, ]) { + final String? customName = uiConfig?.getName?.call(value); + if (customName != null) { + return customName; + } switch (value) { case '1': return ap?.firstSemester ?? '上學期'; @@ -741,6 +754,36 @@ class SemesterPickerState extends State { } } + static String _getShortSemesterName( + String value, [ + ApLocalizations? ap, + SemesterUIConfig? uiConfig, + ]) { + final String? customName = + uiConfig?.getShortName?.call(value) ?? uiConfig?.getName?.call(value); + if (customName != null) { + return customName; + } + switch (value) { + case '1': + return ap?.firstSemesterShort ?? '上'; + case '2': + return ap?.secondSemesterShort ?? '下'; + case '3': + return ap?.winterSessionShort ?? '寒'; + case '4': + return ap?.summerSessionShort ?? '暑'; + case '5': + return ap?.preCourse ?? '先修'; + case '6': + return ap?.summerSessionFirst ?? '暑修(一)'; + case '7': + return ap?.summerSessionSpecial ?? '暑修(特)'; + default: + return ''; + } + } + static IconData _getSemesterIcon(String value) { switch (value) { case '1': From 9b5762d6f283822de95369ca79e3f94d817042a8 Mon Sep 17 00:00:00 2001 From: David Chen Date: Wed, 13 May 2026 03:10:01 +0800 Subject: [PATCH 2/5] feat(ui): add CourseSettings widget for course palette selection --- .../lib/ap_common_flutter_ui.dart | 1 + .../lib/src/widgets/course_settings.dart | 397 ++++++++++++++++++ 2 files changed, 398 insertions(+) create mode 100644 packages/ap_common_flutter_ui/lib/src/widgets/course_settings.dart diff --git a/packages/ap_common_flutter_ui/lib/ap_common_flutter_ui.dart b/packages/ap_common_flutter_ui/lib/ap_common_flutter_ui.dart index 7fad32f7..97eb3ce9 100644 --- a/packages/ap_common_flutter_ui/lib/ap_common_flutter_ui.dart +++ b/packages/ap_common_flutter_ui/lib/ap_common_flutter_ui.dart @@ -29,6 +29,7 @@ export 'src/widgets/ap_network_image.dart'; export 'src/widgets/color_picker_widgets.dart'; export 'src/widgets/course_edit_sheet.dart'; export 'src/widgets/course_palette_picker_sheet.dart'; +export 'src/widgets/course_settings.dart'; export 'src/widgets/default_dialog.dart'; export 'src/widgets/dialog_option.dart'; export 'src/widgets/hint_banner.dart'; diff --git a/packages/ap_common_flutter_ui/lib/src/widgets/course_settings.dart b/packages/ap_common_flutter_ui/lib/src/widgets/course_settings.dart new file mode 100644 index 00000000..7a14aea9 --- /dev/null +++ b/packages/ap_common_flutter_ui/lib/src/widgets/course_settings.dart @@ -0,0 +1,397 @@ +import 'package:ap_common_flutter_ui/ap_common_flutter_ui.dart'; +import 'package:flutter/material.dart'; + +/// Bottom sheet that lets the user pick from a list of course +/// palettes. Shows a 12-dot preview per palette so the user can see +/// the full color range before committing. +/// +/// Call [show] to present the sheet; the resolved [Future] yields the +/// chosen palette, or `null` if dismissed. + +class CourseSettings extends StatefulWidget { + const CourseSettings({ + super.key, + required this.palettes, + required this.currentId, + this.title, + this.settingsTitle, + this.showPaletteSelector, + this.showSettings = false, + this.showSectionTime = true, + this.showInstructors = true, + this.showClassroomLocation = true, + this.showSearchButton = true, + this.mergeCourse = false, + this.adaptiveTextColor = true, + this.showSectionTimeOnChanged, + this.showInstructorsOnChanged, + this.showClassroomLocationOnChanged, + this.showSearchButtonOnChanged, + this.mergeCourseOnChanged, + this.adaptiveTextColorOnChanged, + }); + + final List palettes; + final String currentId; + final String? title; + final String? settingsTitle; + final bool? showPaletteSelector; + final bool showSettings; + final bool showSectionTime; + final bool showInstructors; + final bool showClassroomLocation; + final bool showSearchButton; + final bool mergeCourse; + final bool adaptiveTextColor; + + final ValueChanged? showSectionTimeOnChanged; + final ValueChanged? showInstructorsOnChanged; + final ValueChanged? showClassroomLocationOnChanged; + final ValueChanged? showSearchButtonOnChanged; + final ValueChanged? mergeCourseOnChanged; + final ValueChanged? adaptiveTextColorOnChanged; + + static Future show({ + required BuildContext context, + required String currentId, + List? palettes, + String? settingsTitle, + String? title, + bool showSettings = false, + bool? showSectionTime, + bool? showInstructors, + bool? showClassroomLocation, + bool? showSearchButton, + bool? mergeCourse, + bool? adaptiveTextColor, + bool? showPaletteSelector, + ValueChanged? showSectionTimeOnChanged, + ValueChanged? showInstructorsOnChanged, + ValueChanged? showClassroomLocationOnChanged, + ValueChanged? showSearchButtonOnChanged, + ValueChanged? adaptiveTextColorOnChanged, + ValueChanged? mergeCourseOnChanged, + }) { + return showModalBottomSheet( + context: context, + backgroundColor: const Color(0x00000000), + isScrollControlled: true, + builder: (_) => CourseSettings( + palettes: palettes ?? CoursePaletteTheme.builtIn, + currentId: currentId, + title: title, + settingsTitle: settingsTitle, + showSettings: showSettings, + showSectionTime: showSectionTime ?? true, + showInstructors: showInstructors ?? true, + showClassroomLocation: showClassroomLocation ?? true, + showSearchButton: showSearchButton ?? true, + mergeCourse: mergeCourse ?? false, + adaptiveTextColor: adaptiveTextColor ?? true, + showPaletteSelector: showPaletteSelector ?? true, + showSectionTimeOnChanged: showSectionTimeOnChanged, + showInstructorsOnChanged: showInstructorsOnChanged, + showClassroomLocationOnChanged: showClassroomLocationOnChanged, + showSearchButtonOnChanged: showSearchButtonOnChanged, + mergeCourseOnChanged: mergeCourseOnChanged, + adaptiveTextColorOnChanged: adaptiveTextColorOnChanged, + ), + ); + } + + @override + State createState() => + _CourseSettingsState(); +} + +class _CourseSettingsState extends State { + late bool showSectionTime; + late bool showInstructors; + late bool showClassroomLocation; + late bool showSearchButton; + late bool mergeCourse; + late bool adaptiveTextColor; + + @override + void initState() { + super.initState(); + showSectionTime = widget.showSectionTime; + showInstructors = widget.showInstructors; + showClassroomLocation = widget.showClassroomLocation; + showSearchButton = widget.showSearchButton; + mergeCourse = widget.mergeCourse; + adaptiveTextColor = widget.adaptiveTextColor; + } + + @override + Widget build(BuildContext context) { + final ColorScheme colorScheme = Theme.of(context).colorScheme; + + return DraggableScrollableSheet( + initialChildSize: widget.showPaletteSelector == true ? 0.67 : 0.4, + minChildSize: 0.3, + maxChildSize: 0.9, + expand: false, + builder: (BuildContext context, ScrollController scrollController) { + return Container( + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(24), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildHandle(colorScheme), + Expanded( + child: SingleChildScrollView( + controller: scrollController, + padding: const EdgeInsets.only(bottom: 12), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (widget.showSettings) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 12), + child: Text( + widget.settingsTitle ?? 'Settings', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + ), + CheckboxListTile( + title: Text(context.ap.showSectionTime), + secondary: Icon(ApIcon.accessTime), + value: showSectionTime, + onChanged: (bool? value) { + final bool next = value ?? false; + setState(() => showSectionTime = next); + widget.showSectionTimeOnChanged?.call(next); + }, + ), + CheckboxListTile( + title: Text(context.ap.showInstructors), + secondary: Icon(ApIcon.person), + value: showInstructors, + onChanged: (bool? value) { + final bool next = value ?? false; + setState(() => showInstructors = next); + widget.showInstructorsOnChanged?.call(next); + }, + ), + CheckboxListTile( + title: Text(context.ap.showClassroomLocation), + secondary: Icon(ApIcon.locationOn), + value: showClassroomLocation, + onChanged: (bool? value) { + final bool next = value ?? false; + setState(() => showClassroomLocation = next); + widget.showClassroomLocationOnChanged?.call(next); + }, + ), + CheckboxListTile( + title: Text(context.ap.showSearchButton), + secondary: const Icon(Icons.search), + value: showSearchButton, + onChanged: (bool? value) { + final bool next = value ?? false; + setState(() => showSearchButton = next); + widget.showSearchButtonOnChanged?.call(next); + }, + ), + CheckboxListTile( + title: Text(context.ap.mergeCourse), + secondary: const Icon(Icons.merge_type_rounded), + value: mergeCourse, + onChanged: (bool? value) { + final bool next = value ?? false; + setState(() => mergeCourse = next); + widget.mergeCourseOnChanged?.call(next); + }, + ), + ], + if (widget.showPaletteSelector ?? true) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 12), + child: Text( + widget.title ?? 'Course palette', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + ), + for (final CoursePaletteTheme palette + in widget.palettes) + _PaletteRow( + palette: palette, + selected: palette.id == widget.currentId, + onTap: () => Navigator.of(context).pop(palette), + ), + ], + const SizedBox(height: 8), + ], + ), + ), + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildHandle(ColorScheme colorScheme) { + return Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(top: 12, bottom: 4), + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withAlpha(77), + borderRadius: BorderRadius.circular(2), + ), + ), + ); + } +} + +class _ModeBadge extends StatelessWidget { + const _ModeBadge({required this.label, required this.colorScheme}); + + final String label; + final ColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + label, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: colorScheme.onSecondaryContainer, + ), + ), + ); + } +} + +class _SwatchRow extends StatelessWidget { + const _SwatchRow({required this.colors, required this.label}); + + final List colors; + final String label; + + @override + Widget build(BuildContext context) { + final ColorScheme colorScheme = Theme.of(context).colorScheme; + return Row( + children: [ + SizedBox( + width: 36, + child: Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final Color color in colors) + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ), + ], + ), + ), + ], + ); + } +} + +class _PaletteRow extends StatelessWidget { + const _PaletteRow({ + required this.palette, + required this.selected, + required this.onTap, + }); + + final CoursePaletteTheme palette; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final ColorScheme colorScheme = Theme.of(context).colorScheme; + final CoursePaletteTheme? darkVariant = palette.dark; + + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + palette.name, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + ), + if (darkVariant != null) + _ModeBadge(label: 'Auto dark', colorScheme: colorScheme), + if (selected) ...[ + const SizedBox(width: 6), + Icon( + Icons.check_rounded, + size: 20, + color: colorScheme.primary, + ), + ], + ], + ), + const SizedBox(height: 8), + _SwatchRow(colors: palette.colors, label: 'Light'), + if (darkVariant != null) ...[ + const SizedBox(height: 6), + _SwatchRow(colors: darkVariant.colors, label: 'Dark'), + ], + ], + ), + ), + ); + } +} From 02f0e178f56ac3895a90a7226f493a37dde8efcb Mon Sep 17 00:00:00 2001 From: David Chen Date: Wed, 13 May 2026 03:10:51 +0800 Subject: [PATCH 3/5] feat(ui): add menu icon and search button configuration to CourseScaffold --- .../lib/src/ui/ap_icon.dart | 10 + .../lib/src/scaffold/course_scaffold.dart | 509 +++++++----------- 2 files changed, 216 insertions(+), 303 deletions(-) diff --git a/packages/ap_common_flutter_core/lib/src/ui/ap_icon.dart b/packages/ap_common_flutter_core/lib/src/ui/ap_icon.dart index 16cee1aa..4cb12f08 100644 --- a/packages/ap_common_flutter_core/lib/src/ui/ap_icon.dart +++ b/packages/ap_common_flutter_core/lib/src/ui/ap_icon.dart @@ -454,4 +454,14 @@ class ApIcon { return Icons.home_outlined; } } + + static IconData get menu { + switch (ApIcon.code) { + case ApIcon.filled: + return Icons.menu; + case ApIcon.outlined: + default: + return Icons.menu_outlined; + } + } } diff --git a/packages/ap_common_flutter_ui/lib/src/scaffold/course_scaffold.dart b/packages/ap_common_flutter_ui/lib/src/scaffold/course_scaffold.dart index 3b186472..b6131d9c 100644 --- a/packages/ap_common_flutter_ui/lib/src/scaffold/course_scaffold.dart +++ b/packages/ap_common_flutter_ui/lib/src/scaffold/course_scaffold.dart @@ -45,12 +45,14 @@ class CourseConfig extends InheritedWidget { this.showSectionTime, this.showInstructors, this.showClassroomLocation, + this.showSearchButton, required super.child, }); final bool? showSectionTime; final bool? showInstructors; final bool? showClassroomLocation; + final bool? showSearchButton; static CourseConfig of(BuildContext context) { return context.dependOnInheritedWidgetOfExactType()!; @@ -92,6 +94,7 @@ class CourseScaffold extends StatefulWidget { this.customCourseData, this.onCustomCourseChanged, this.semesterPickerController, + this.semesterPickerUiConfig, this.enablePaletteSelector = true, this.onCoursePaletteChanged, }); @@ -134,6 +137,7 @@ class CourseScaffold extends StatefulWidget { this.customCourseData, this.onCustomCourseChanged, this.semesterPickerController, + this.semesterPickerUiConfig, this.enablePaletteSelector = true, this.onCoursePaletteChanged, }) : state = dataState.when( @@ -190,6 +194,9 @@ class CourseScaffold extends StatefulWidget { /// Optional controller for the semester picker. final SemesterPickerController? semesterPickerController; + /// Optional UI configuration for the semester picker. + final SemesterUIConfig? semesterPickerUiConfig; + /// When true (default), the setting dialog shows a palette selector /// that lets the user switch between built-in [CoursePaletteTheme]s. /// The choice is persisted under [CoursePaletteTheme.preferenceKey] @@ -371,14 +378,7 @@ class CourseScaffoldState extends State { } } - Future _showPalettePicker() async { - final CoursePaletteTheme current = _activePalette(context); - final CoursePaletteTheme? picked = await CoursePalettePickerSheet.show( - context: context, - currentId: current.id, - title: context.ap.courseColor, - ); - if (picked == null || picked.id == current.id) return; + Future _applyPickedPalette(CoursePaletteTheme picked) async { await PreferenceUtil.instance .setString(CoursePaletteTheme.preferenceKey, picked.id); unawaited(_syncCoursePaletteToNative(picked)); @@ -393,27 +393,25 @@ class CourseScaffoldState extends State { return _withPaletteOverride( context, CourseConfig( - showSectionTime: showSectionTime, - showInstructors: showInstructors, - showClassroomLocation: showClassroomLocation, - child: Scaffold( - appBar: AppBar( - titleSpacing: 0, - title: Row( - children: [ - Flexible( - child: Text( - widget.title ?? context.ap.course, - overflow: TextOverflow.ellipsis, - ), - ), + showSectionTime: showSectionTime, + showInstructors: showInstructors, + showClassroomLocation: showClassroomLocation, + child: Scaffold( + appBar: AppBar( + centerTitle: false, + titleSpacing: 0, + title: Text( + widget.title ?? context.ap.course, + overflow: TextOverflow.ellipsis, + ), + actions: [ + ...widget.actions ?? [], if (widget.itemPicker != null) ...[ - const SizedBox(width: 8), widget.itemPicker!, + const SizedBox(width: 8), ], if (widget.semesterData != null && widget.itemPicker == null) ...[ - const SizedBox(width: 8), SemesterPicker( semesterData: widget.semesterData!, currentIndex: widget.semesterData!.currentIndex, @@ -422,181 +420,188 @@ class CourseScaffoldState extends State { }, featureTag: 'course', controller: widget.semesterPickerController, + uiConfig: widget.semesterPickerUiConfig, ), + const SizedBox(width: 8), ], - ], - ), - actions: [ - ...widget.actions ?? [], - if (widget.enableCaptureCourseTable) - IconButton( - icon: Icon(ApIcon.download), - onPressed: _captureCourseTable, - tooltip: context.ap.exportCourseTable, - ), - if (widget.enablePaletteSelector) + if (widget.enableCaptureCourseTable) + IconButton( + icon: Icon(ApIcon.download), + onPressed: _captureCourseTable, + tooltip: context.ap.exportCourseTable, + ), IconButton( - icon: const Icon(Icons.palette_outlined), - onPressed: _showPalettePicker, - tooltip: context.ap.courseColor, - ), - IconButton( - icon: Icon(ApIcon.settings), - onPressed: () { - showDialog( - context: context, - builder: (_) => CourseScaffoldSettingDialog( + icon: Icon(ApIcon.menu), + onPressed: () async { + final String currentId = _overridePalette?.id ?? + Theme.of(context).extension()?.id ?? + CoursePaletteTheme.material.id; + final CoursePaletteTheme? picked = await CourseSettings.show( + context: context, + currentId: currentId, + title: context.ap.courseColor, + settingsTitle: context.ap.courseScaffoldSetting, + showPaletteSelector: widget.enablePaletteSelector, + showSettings: true, showSectionTime: showSectionTime, showInstructors: showInstructors, showClassroomLocation: showClassroomLocation, showSearchButton: showSearchButton, mergeCourse: mergeCourse, showSectionTimeOnChanged: (bool? value) { - setState(() => showSectionTime = value); + final bool next = value ?? false; + setState(() => showSectionTime = next); PreferenceUtil.instance.setBool( ApConstants.showSectionTime, - showSectionTime!, + next, ); }, showInstructorsOnChanged: (bool? value) { - setState(() => showInstructors = value); + final bool next = value ?? false; + setState(() => showInstructors = next); PreferenceUtil.instance.setBool( ApConstants.showInstructors, - showInstructors!, + next, ); }, showClassroomLocationOnChanged: (bool? value) { - setState(() => showClassroomLocation = value); + final bool next = value ?? false; + setState(() => showClassroomLocation = next); PreferenceUtil.instance.setBool( ApConstants.showClassroomLocation, - showClassroomLocation!, + next, ); }, showSearchButtonOnChanged: (bool? value) { - setState(() => showSearchButton = value); + final bool next = value ?? false; + setState(() => showSearchButton = next); PreferenceUtil.instance.setBool( ApConstants.showCourseSearchButton, - showSearchButton!, + next, ); }, mergeCourseOnChanged: (bool? value) { - setState(() => mergeCourse = value); + final bool next = value ?? false; + setState(() => mergeCourse = next); PreferenceUtil.instance.setBool( '${ApConstants.packageName}.merge_course', - mergeCourse!, + next, ); }, - ), - ); - AnalyticsUtil.instance.logEvent('course_setting_click'); - }, - tooltip: context.ap.courseScaffoldSetting, - ), - ], - ), - body: Row( - children: [ - Expanded( - flex: 3, - child: Flex( - direction: Axis.vertical, - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - if (widget.customHint != null && - widget.customHint!.isNotEmpty) - _buildHintBanner(), - Expanded( - child: RefreshIndicator( - onRefresh: () async { - await widget.onRefresh!(); - AnalyticsUtil.instance.logEvent('course_refresh'); - return; - }, - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: KeyedSubtree( - key: ValueKey(widget.state), - child: _body(), + ); + if (picked != null && picked.id != currentId) { + await _applyPickedPalette(picked); + } + AnalyticsUtil.instance.logEvent('course_setting_click'); + }, + tooltip: context.ap.courseScaffoldSetting, + ), + ], + ), + body: Row( + children: [ + Expanded( + flex: 3, + child: Flex( + direction: Axis.vertical, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + if (widget.customHint != null && + widget.customHint!.isNotEmpty) + _buildHintBanner(), + Expanded( + child: RefreshIndicator( + onRefresh: () async { + await widget.onRefresh!(); + AnalyticsUtil.instance.logEvent('course_refresh'); + return; + }, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: KeyedSubtree( + key: ValueKey(widget.state), + child: _body(), + ), ), ), ), - ), - ], + ], + ), ), - ), - if (widget.state == CourseState.finish && isLandscape) ...[ - const SizedBox(width: 16.0), - Expanded( - flex: 2, - child: Material( - elevation: 12.0, - child: ColoredBox( - color: - Theme.of(context).colorScheme.surfaceContainerHighest, - child: CourseList( - courses: widget.courseData.courses, - timeCodes: widget.courseData.timeCodes, - invisibleCourseCodes: invisibleCourseCodes, - getCourseColor: _getCourseColor, - onVisibilityChanged: ( - Course course, - bool visibility, - ) => - saveInvisibleCourseCodes( - course: course, - visibility: visibility, + if (widget.state == CourseState.finish && + isLandscape) ...[ + const SizedBox(width: 16.0), + Expanded( + flex: 2, + child: Material( + elevation: 12.0, + child: ColoredBox( + color: + Theme.of(context).colorScheme.surfaceContainerHighest, + child: CourseList( + courses: widget.courseData.courses, + timeCodes: widget.courseData.timeCodes, + invisibleCourseCodes: invisibleCourseCodes, + getCourseColor: _getCourseColor, + onVisibilityChanged: ( + Course course, + bool visibility, + ) => + saveInvisibleCourseCodes( + course: course, + visibility: visibility, + ), ), ), ), ), - ), + ], ], - ], - ), - floatingActionButton: AnimatedScale( - scale: _showFab ? 1.0 : 0.0, - duration: const Duration(milliseconds: 250), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (!isLandscape) - FloatingActionButton( - key: const ValueKey('switch_content_style_button'), - heroTag: 'switch_content_style_button', - onPressed: () { - setState( - () => _contentStyle = - (_contentStyle == _ContentStyle.table) - ? _ContentStyle.list - : _ContentStyle.table, - ); - }, - child: Icon( - _contentStyle == _ContentStyle.table - ? Icons.list_rounded - : Icons.grid_view_rounded, + ), + floatingActionButton: AnimatedScale( + scale: _showFab ? 1.0 : 0.0, + duration: const Duration(milliseconds: 250), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (!isLandscape) + FloatingActionButton( + key: const ValueKey('switch_content_style_button'), + heroTag: 'switch_content_style_button', + onPressed: () { + setState( + () => _contentStyle = + (_contentStyle == _ContentStyle.table) + ? _ContentStyle.list + : _ContentStyle.table, + ); + }, + child: Icon( + _contentStyle == _ContentStyle.table + ? Icons.list_rounded + : Icons.grid_view_rounded, + ), ), - ), - if (showSearchButton ?? true) ...[ - if (!isLandscape) const SizedBox(height: 8), - FloatingActionButton( - key: const ValueKey('search_button'), - heroTag: 'search_button', - onPressed: () { - _pickSemester(); - AnalyticsUtil.instance - .logEvent('course_search_button_click'); - }, - child: const Icon(Icons.search), - ), + if (showSearchButton ?? true) ...[ + if (!isLandscape) const SizedBox(height: 8), + FloatingActionButton( + key: const ValueKey('search_button'), + heroTag: 'search_button', + onPressed: () { + _pickSemester(); + AnalyticsUtil.instance + .logEvent('course_search_button_click'); + }, + child: const Icon(Icons.search), + ), + ], ], - ], + ), ), ), ), - ), ); } @@ -765,8 +770,7 @@ class CourseScaffoldState extends State { } final double pixelRatio = MediaQuery.devicePixelRatioOf(context).clamp(2.0, 4.0); - final ui.Image image = - await boundary.toImage(pixelRatio: pixelRatio); + final ui.Image image = await boundary.toImage(pixelRatio: pixelRatio); final ByteData? byteData = await image.toByteData(format: ui.ImageByteFormat.png); final DateTime now = DateTime.now(); @@ -1082,7 +1086,7 @@ class CourseScaffoldState extends State { final String instructorInfo = (showInstructors ?? true) ? course.getInstructors() : ''; - final Color onCourseColor = CoursePaletteTheme.of(context).foregroundColor; + final Color onCourseColor = _activePalette(context).foregroundColor; final String displayInfo = [ if (instructorInfo.isNotEmpty) instructorInfo, @@ -1163,28 +1167,28 @@ class CourseScaffoldState extends State { return _withPaletteOverride( builder, CourseContent( - enableNotifyControl: widget.enableNotifyControl, - course: course, - notifyData: widget.notifyData, - weekday: weekday, - courseNotifySaveKey: widget.courseNotifySaveKey, - timeCode: timeCode, - courseColor: _getCourseColor(course), - invisibleCourseCodes: invisibleCourseCodes, - onVisibilityChanged: (bool visibility) => saveInvisibleCourseCodes( + enableNotifyControl: widget.enableNotifyControl, course: course, - visibility: visibility, - ), - enableCustomCourseEdit: - widget.enableCustomCourse && course.isCustomCourse, - onEditCourse: () { - Navigator.of(builder).pop(); - _editCustomCourse(course); - }, - onDeleteCourse: () { - Navigator.of(builder).pop(); - _deleteCustomCourse(course); - }, + notifyData: widget.notifyData, + weekday: weekday, + courseNotifySaveKey: widget.courseNotifySaveKey, + timeCode: timeCode, + courseColor: _getCourseColor(course), + invisibleCourseCodes: invisibleCourseCodes, + onVisibilityChanged: (bool visibility) => saveInvisibleCourseCodes( + course: course, + visibility: visibility, + ), + enableCustomCourseEdit: + widget.enableCustomCourse && course.isCustomCourse, + onEditCourse: () { + Navigator.of(builder).pop(); + _editCustomCourse(course); + }, + onDeleteCourse: () { + Navigator.of(builder).pop(); + _deleteCustomCourse(course); + }, ), ); }, @@ -1365,8 +1369,8 @@ class _CourseContentState extends State { !widget.invisibleCourseCodes.contains(widget.course.code); final ColorScheme colorScheme = Theme.of(context).colorScheme; final CoursePaletteTheme palette = CoursePaletteTheme.of(context); - final Color courseColor = widget.courseColor ?? - palette.colorAt(widget.course.code.hashCode); + final Color courseColor = + widget.courseColor ?? palette.colorAt(widget.course.code.hashCode); final Color onCourseColor = palette.foregroundColor; return Container( decoration: BoxDecoration( @@ -1742,33 +1746,33 @@ class CourseList extends StatelessWidget { isScrollControlled: true, builder: (BuildContext sheetCtx) { final ThemeData sheetTheme = Theme.of(sheetCtx); - final List> merged = sheetTheme - .extensions.values - .where( - (ThemeExtension ext) => - ext is! CoursePaletteTheme, - ) - .toList() - ..add(palette); + final List> merged = + sheetTheme.extensions.values + .where( + (ThemeExtension ext) => + ext is! CoursePaletteTheme, + ) + .toList() + ..add(palette); return Theme( data: sheetTheme.copyWith(extensions: merged), child: CourseContent( - course: course, - invisibleCourseCodes: invisibleCourseCodes, - onVisibilityChanged: (bool visible) => - onVisibilityChanged?.call(course, visible), - timeCode: timeCodes != null && timeCodes!.isNotEmpty - ? timeCodes![0] - : const TimeCode( - title: '', - startTime: '', - endTime: '', - ), - weekday: course.times.isNotEmpty - ? course.times.first.weekday - : 1, - courseColor: courseColor, - enableNotifyControl: false, + course: course, + invisibleCourseCodes: invisibleCourseCodes, + onVisibilityChanged: (bool visible) => + onVisibilityChanged?.call(course, visible), + timeCode: timeCodes != null && timeCodes!.isNotEmpty + ? timeCodes![0] + : const TimeCode( + title: '', + startTime: '', + endTime: '', + ), + weekday: course.times.isNotEmpty + ? course.times.first.weekday + : 1, + courseColor: courseColor, + enableNotifyControl: false, ), ); }, @@ -1868,107 +1872,6 @@ class CourseList extends StatelessWidget { } } -class CourseScaffoldSettingDialog extends StatefulWidget { - const CourseScaffoldSettingDialog({ - super.key, - required this.showSectionTime, - required this.showInstructors, - required this.showClassroomLocation, - required this.showSearchButton, - required this.mergeCourse, - this.showSectionTimeOnChanged, - this.showInstructorsOnChanged, - this.showClassroomLocationOnChanged, - this.showSearchButtonOnChanged, - this.mergeCourseOnChanged, - }); - - final bool? showSectionTime; - final bool? showInstructors; - final bool? showClassroomLocation; - final bool? showSearchButton; - final bool? mergeCourse; - final Function(bool?)? showSectionTimeOnChanged; - final Function(bool?)? showInstructorsOnChanged; - final Function(bool?)? showClassroomLocationOnChanged; - final Function(bool?)? showSearchButtonOnChanged; - final Function(bool?)? mergeCourseOnChanged; - - @override - _CourseScaffoldSettingDialogState createState() => - _CourseScaffoldSettingDialogState(); -} - -class _CourseScaffoldSettingDialogState - extends State { - bool? showSectionTime; - bool? showInstructors; - bool? showClassroomLocation; - bool? showSearchButton; - bool? mergeCourse; - - @override - void initState() { - showSectionTime = widget.showSectionTime; - showInstructors = widget.showInstructors; - showClassroomLocation = widget.showClassroomLocation; - showSearchButton = widget.showSearchButton; - mergeCourse = widget.mergeCourse; - super.initState(); - } - - @override - Widget build(BuildContext context) { - return DefaultDialog( - title: context.ap.courseScaffoldSetting, - actionText: context.ap.confirm, - actionFunction: () => Navigator.of(context, rootNavigator: true).pop(), - contentPadding: EdgeInsets.zero, - contentWidget: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CheckboxListTile( - title: Text(context.ap.showSectionTime), - secondary: Icon(ApIcon.accessTime), - value: showSectionTime, - onChanged: (bool? value) { - setState(() => showSectionTime = value); - widget.showSectionTimeOnChanged?.call(value); - }, - ), - CheckboxListTile( - title: Text(context.ap.showInstructors), - secondary: Icon(ApIcon.person), - value: showInstructors, - onChanged: (bool? value) { - setState(() => showInstructors = value); - widget.showInstructorsOnChanged?.call(value); - }, - ), - CheckboxListTile( - title: Text(context.ap.showClassroomLocation), - secondary: Icon(ApIcon.locationOn), - value: showClassroomLocation, - onChanged: (bool? value) { - setState(() => showClassroomLocation = value); - widget.showClassroomLocationOnChanged?.call(value); - }, - ), - CheckboxListTile( - title: Text(context.ap.mergeCourse), - secondary: const Icon(Icons.merge_type_rounded), - value: mergeCourse, - onChanged: (bool? value) { - setState(() => mergeCourse = value); - widget.mergeCourseOnChanged?.call(value); - }, - ), - ], - ), - ); - } -} - extension DateTimeExtension on DateTime { DateTime weekTime(int weekday) { final DateTime now = DateTime.now(); From ee08c74191b4793818688ae9b8397d0f1b5feb7b Mon Sep 17 00:00:00 2001 From: David Chen Date: Wed, 13 May 2026 03:11:25 +0800 Subject: [PATCH 4/5] feat(ui): add Luster color palette with light and dark variants to CoursePaletteTheme --- .../lib/src/theme/course_palette_theme.dart | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/ap_common_flutter_ui/lib/src/theme/course_palette_theme.dart b/packages/ap_common_flutter_ui/lib/src/theme/course_palette_theme.dart index 99c25eb3..9f570b1b 100644 --- a/packages/ap_common_flutter_ui/lib/src/theme/course_palette_theme.dart +++ b/packages/ap_common_flutter_ui/lib/src/theme/course_palette_theme.dart @@ -261,6 +261,41 @@ class CoursePaletteTheme extends ThemeExtension { foregroundColor: Colors.white, ); + static const CoursePaletteTheme luster = CoursePaletteTheme( + id: 'luster', + name: 'Luster', + colors: [ + // https://coolors.co/e3425d-e39959-e0b852-58c36e-56adc0-3d7dff-674fff-e762ff + Color(0xFFE3425D), // Ruby + Color(0xFFE39959), // Amber + Color(0xFFE0B852), // Topaz + Color(0xFF58C36E), // Emerald + Color(0xFF56ADC0), // Aquamarine + Color(0xFF3D7DFF), // Sapphire + Color(0xFF674FFF), // Amethyst + Color(0xFFE762FF), // Opal + ], + foregroundColor: Colors.white, + dark: _lusterDark, + ); + + static const CoursePaletteTheme _lusterDark = CoursePaletteTheme( + id: 'lusterDark', + name: 'Luster Dark', + colors: [ + //https://coolors.co/ff5c5c-ffb65c-fecf34-73d377-73bdbf-5c9aff-8670ff-ff85ff + Color(0xFFFF7070), // Tomato + Color(0xFFFFB65C), // Tangerine + Color(0xFFFFCF34), // Banana + Color(0xFF73D377), // Sage + Color(0xFF73BDBF), // Basil + Color(0xFF5C9AFF), // Peacock + Color(0xFF8670FF), // Blueberry + Color(0xFFFF85FF), // Lavender + ], + foregroundColor: Colors.black, + ); + /// All built-in palettes shown in pickers, in display order. Dark /// variants are intentionally omitted — they are surfaced only via /// the [dark] field on their light parent. @@ -271,6 +306,7 @@ class CoursePaletteTheme extends ThemeExtension { nord, vibrant, pastel, + luster, ]; @override @@ -307,9 +343,8 @@ class CoursePaletteTheme extends ThemeExtension { id: pickOther ? other.id : id, name: pickOther ? other.name : name, colors: lerped, - foregroundColor: - Color.lerp(foregroundColor, other.foregroundColor, t) ?? - foregroundColor, + foregroundColor: Color.lerp(foregroundColor, other.foregroundColor, t) ?? + foregroundColor, dark: pickOther ? other.dark : dark, ); } From 14297050f92a2f71943e917b427bd9de2cef3b10 Mon Sep 17 00:00:00 2001 From: David Chen <129130455+abcd1234davidchen@users.noreply.github.com> Date: Wed, 13 May 2026 21:00:09 +0800 Subject: [PATCH 5/5] Update packages/ap_common_flutter_ui/lib/src/widgets/course_settings.dart Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../ap_common_flutter_ui/lib/src/widgets/course_settings.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ap_common_flutter_ui/lib/src/widgets/course_settings.dart b/packages/ap_common_flutter_ui/lib/src/widgets/course_settings.dart index 7a14aea9..777e9f61 100644 --- a/packages/ap_common_flutter_ui/lib/src/widgets/course_settings.dart +++ b/packages/ap_common_flutter_ui/lib/src/widgets/course_settings.dart @@ -128,7 +128,7 @@ class _CourseSettingsState extends State { final ColorScheme colorScheme = Theme.of(context).colorScheme; return DraggableScrollableSheet( - initialChildSize: widget.showPaletteSelector == true ? 0.67 : 0.4, + initialChildSize: (widget.showPaletteSelector ?? true) ? 0.67 : 0.4, minChildSize: 0.3, maxChildSize: 0.9, expand: false,