diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 9c8ddedb..50f53e39 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,4 +1,4 @@ -#Sat Feb 15 15:33:20 CST 2025 +#Thu Apr 16 09:43:18 CST 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip diff --git a/assets/flutter_i18n/en_US.yaml b/assets/flutter_i18n/en_US.yaml index 22121a17..690ec3e0 100644 --- a/assets/flutter_i18n/en_US.yaml +++ b/assets/flutter_i18n/en_US.yaml @@ -159,6 +159,25 @@ classtable: output_to_system: "Export to system calendar" refresh_classtable: "Refresh schedule" switch_semester: "Switch classtable semester" + current_time_settings: "Time indicator settings" + class_color_settings: "Class color settings" + visual_settings: + current_time_settings_title: "Time indicator settings" + class_color_settings_title: "Class color settings" + current_time_section: "Time indicators" + show_current_time_indicator: "Show current time indicator" + show_current_time_label: "Show mini time label" + show_today_column_highlight: "Highlight today's column" + unfinished_section: "Unstarted class style" + active_brightness_factor: "Brightness: {value}" + active_border_alpha: "Border opacity: {value}" + active_inner_alpha: "Fill opacity: {value}" + completed_section: "Completed class style" + completed_saturation_factor: "Fill saturation: {value}" + completed_brightness_factor: "Brightness: {value}" + completed_text_saturation_factor: "Text saturation: {value}" + completed_border_alpha: "Border opacity: {value}" + completed_inner_alpha: "Fill opacity: {value}" status_source: class_table: "Class Table" exam: "Exams" diff --git a/assets/flutter_i18n/zh_CN.yaml b/assets/flutter_i18n/zh_CN.yaml index 65170e1c..b23467b1 100644 --- a/assets/flutter_i18n/zh_CN.yaml +++ b/assets/flutter_i18n/zh_CN.yaml @@ -154,6 +154,25 @@ classtable: output_to_system: "导出到系统日历" refresh_classtable: "刷新日程表" switch_semester: "切换课程表学期" + current_time_settings: "时间指示设置" + class_color_settings: "课表样式设置" + visual_settings: + current_time_settings_title: "时间指示设置" + class_color_settings_title: "课表样式设置" + current_time_section: "时间指示" + show_current_time_indicator: "显示当前时间指示线" + show_current_time_label: "显示迷你数字时钟" + show_today_column_highlight: "强调显示今天的纵列" + unfinished_section: "未开始课程样式" + active_brightness_factor: "亮度: {value}" + active_border_alpha: "边框透明度: {value}" + active_inner_alpha: "底色透明度: {value}" + completed_section: "已结束课程样式" + completed_saturation_factor: "底色饱和度: {value}" + completed_brightness_factor: "亮度: {value}" + completed_text_saturation_factor: "文字饱和度: {value}" + completed_border_alpha: "边框透明度: {value}" + completed_inner_alpha: "底色透明度: {value}" status_source: class_table: "课表" exam: "考试" diff --git a/assets/flutter_i18n/zh_TW.yaml b/assets/flutter_i18n/zh_TW.yaml index b51901b1..109d10ea 100644 --- a/assets/flutter_i18n/zh_TW.yaml +++ b/assets/flutter_i18n/zh_TW.yaml @@ -130,6 +130,25 @@ classtable: output_to_system: 導出到系統日曆 refresh_classtable: 刷新日程表 switch_semester: 切換課程表學期 + current_time_settings: 時間指示設置 + class_color_settings: 课表样式设置 + visual_settings: + current_time_settings_title: 時間指示設置 + class_color_settings_title: 课表样式设置 + current_time_section: 時間指示 + show_current_time_indicator: 顯示當前時間指示線 + show_current_time_label: 顯示迷你数字時钟 + show_today_column_highlight: 強調顯示今天的纵列 + unfinished_section: 未开始課程樣式 + active_brightness_factor: 亮度: {value} + active_border_alpha: 邊框透明度: {value} + active_inner_alpha: 底色透明度: {value} + completed_section: 已结束課程樣式 + completed_saturation_factor: 底色飽和度: {value} + completed_brightness_factor: 亮度: {value} + completed_text_saturation_factor: 文字飽和度: {value} + completed_border_alpha: 邊框透明度: {value} + completed_inner_alpha: 底色透明度: {value} class_change_page: title: 課程調整 empty_message: 目前沒有調課信息 diff --git a/lib/page/classtable/class_page/classtable_page.dart b/lib/page/classtable/class_page/classtable_page.dart index 0a8980ca..46025cea 100644 --- a/lib/page/classtable/class_page/classtable_page.dart +++ b/lib/page/classtable/class_page/classtable_page.dart @@ -12,19 +12,31 @@ class ClassTablePage extends StatefulWidget { class _ClassTablePageState extends State { late ClassTableWidgetState classTableState; + ClassTableWidgetState? _attachedClassTableState; - void _reload() => setState(() {}); + void _reload() { + if (mounted) { + setState(() {}); + } + } @override void didChangeDependencies() { super.didChangeDependencies(); - classTableState = ClassTableState.of(context)!.controllers; - classTableState.addListener(_reload); + final nextClassTableState = ClassTableState.of(context)!.controllers; + + if (_attachedClassTableState != nextClassTableState) { + _attachedClassTableState?.removeListener(_reload); + _attachedClassTableState = nextClassTableState; + _attachedClassTableState!.addListener(_reload); + } + + classTableState = nextClassTableState; } @override void dispose() { - classTableState.removeListener(_reload); + _attachedClassTableState?.removeListener(_reload); classTableState.dispose(); super.dispose(); } diff --git a/lib/page/classtable/class_page/content_classtable_page.dart b/lib/page/classtable/class_page/content_classtable_page.dart index f23feb5b..e164628a 100644 --- a/lib/page/classtable/class_page/content_classtable_page.dart +++ b/lib/page/classtable/class_page/content_classtable_page.dart @@ -16,6 +16,8 @@ import 'package:watermeter/page/classtable/class_add/class_add_window.dart'; import 'package:watermeter/page/classtable/class_page/class_change_list.dart'; import 'package:watermeter/page/classtable/class_page/classtable_inline_banner.dart'; import 'package:watermeter/page/classtable/class_table_view/class_table_view.dart'; +import 'package:watermeter/page/classtable/class_table_view/completed_class_style.dart'; +import 'package:watermeter/page/classtable/class_table_view/current_time_indicator.dart'; import 'package:watermeter/page/classtable/classtable_constant.dart'; import 'package:watermeter/page/classtable/classtable_state.dart'; import 'package:watermeter/page/classtable/class_page/not_arranged_class_list.dart'; @@ -48,8 +50,12 @@ class _ContentClassTablePageState extends State { late BoxDecoration decoration; late ClassTableWidgetState classTableState; + ClassTableWidgetState? _attachedClassTableState; void _switchPage() { + if (!mounted) { + return; + } setState(() => isTopRowLocked = true); Future.wait([ rowControl.animateToPage( @@ -62,18 +68,30 @@ class _ContentClassTablePageState extends State { curve: Curves.easeInOutCubic, duration: const Duration(milliseconds: changePageTime), ), - ]).then((value) => isTopRowLocked = false); + ]).then((value) { + if (mounted) { + isTopRowLocked = false; + } + }); } @override void dispose() { - classTableState.removeListener(_switchPage); + _attachedClassTableState?.removeListener(_switchPage); super.dispose(); } @override void didChangeDependencies() { - classTableState = ClassTableState.of(context)!.controllers; + final nextClassTableState = ClassTableState.of(context)!.controllers; + + if (_attachedClassTableState != nextClassTableState) { + _attachedClassTableState?.removeListener(_switchPage); + _attachedClassTableState = nextClassTableState; + _attachedClassTableState!.addListener(_switchPage); + } + + classTableState = nextClassTableState; pageControl = PageController( initialPage: classTableState.chosenWeek, @@ -91,11 +109,6 @@ class _ContentClassTablePageState extends State { ); /// Let controllers listen to the currentWeek's change. - // if (isPushedListener == false) { - classTableState.addListener(_switchPage); - // isPushedListener = true; - //} - /// Init the background. File image = File("${supportPath.path}/${classTableState.decorationName}"); decoration = BoxDecoration( @@ -249,6 +262,333 @@ class _ContentClassTablePageState extends State { ); } + String _formatPercent(double value) => "${(value * 100).round()}%"; + + Future _showCurrentTimeSettingsDialog() async { + var enabled = CurrentTimeIndicatorConfig.enabled; + var showTimeLabel = CurrentTimeIndicatorConfig.showTimeLabel; + var showTodayColumnHighlight = + CurrentTimeIndicatorConfig.showTodayColumnHighlight; + + final shouldApply = + await showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: Text( + FlutterI18n.translate( + context, + "classtable.visual_settings.current_time_settings_title", + ), + ), + content: SizedBox( + width: 420, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text( + FlutterI18n.translate( + context, + "classtable.visual_settings.show_current_time_indicator", + ), + ), + value: enabled, + onChanged: (value) => + setDialogState(() => enabled = value), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text( + FlutterI18n.translate( + context, + "classtable.visual_settings.show_current_time_label", + ), + ), + value: showTimeLabel, + onChanged: enabled + ? (value) => + setDialogState(() => showTimeLabel = value) + : null, + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text( + FlutterI18n.translate( + context, + "classtable.visual_settings.show_today_column_highlight", + ), + ), + value: showTodayColumnHighlight, + onChanged: (value) => setDialogState( + () => showTodayColumnHighlight = value, + ), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(FlutterI18n.translate(context, "cancel")), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(FlutterI18n.translate(context, "confirm")), + ), + ], + ), + ), + ) ?? + false; + + if (!shouldApply || !mounted) { + return; + } + + CurrentTimeIndicatorConfig.enabled = enabled; + CurrentTimeIndicatorConfig.showTimeLabel = showTimeLabel; + CurrentTimeIndicatorConfig.showTodayColumnHighlight = + showTodayColumnHighlight; + setState(() {}); + } + + Future _showClassColorSettingsDialog() async { + var activeBrightnessFactor = CompletedClassStyleConfig + .activeBrightnessFactor + .clamp(0.5, 1.0) + .toDouble(); + var activeBorderAlpha = CompletedClassStyleConfig.activeBorderAlpha; + var activeInnerAlpha = CompletedClassStyleConfig.activeInnerAlpha; + var completedSaturationFactor = + CompletedClassStyleConfig.completedSaturationFactor; + var completedBrightnessFactor = CompletedClassStyleConfig + .completedBrightnessFactor + .clamp(0.5, 1.0) + .toDouble(); + var completedTextSaturationFactor = + CompletedClassStyleConfig.completedTextSaturationFactor; + var completedBorderAlpha = CompletedClassStyleConfig.completedBorderAlpha; + var completedInnerAlpha = CompletedClassStyleConfig.completedInnerAlpha; + + final shouldApply = + await showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: Text( + FlutterI18n.translate( + context, + "classtable.visual_settings.class_color_settings_title", + ), + ), + content: SizedBox( + width: 420, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + FlutterI18n.translate( + context, + "classtable.visual_settings.unfinished_section", + ), + style: const TextStyle(fontWeight: FontWeight.w700), + ), + Text( + FlutterI18n.translate( + context, + "classtable.visual_settings.active_brightness_factor", + translationParams: { + "value": _formatPercent(activeBrightnessFactor), + }, + ), + ), + Slider( + value: activeBrightnessFactor, + min: 0.5, + max: 1.0, + divisions: 10, + onChanged: (value) => setDialogState( + () => activeBrightnessFactor = value, + ), + ), + Text( + FlutterI18n.translate( + context, + "classtable.visual_settings.active_border_alpha", + translationParams: { + "value": _formatPercent(activeBorderAlpha), + }, + ), + ), + Slider( + value: activeBorderAlpha, + min: 0.1, + max: 1.0, + divisions: 18, + onChanged: (value) => + setDialogState(() => activeBorderAlpha = value), + ), + Text( + FlutterI18n.translate( + context, + "classtable.visual_settings.active_inner_alpha", + translationParams: { + "value": _formatPercent(activeInnerAlpha), + }, + ), + ), + Slider( + value: activeInnerAlpha, + min: 0.1, + max: 1.0, + divisions: 18, + onChanged: (value) => + setDialogState(() => activeInnerAlpha = value), + ), + const Divider(height: 24), + Text( + FlutterI18n.translate( + context, + "classtable.visual_settings.completed_section", + ), + style: const TextStyle(fontWeight: FontWeight.w700), + ), + Text( + FlutterI18n.translate( + context, + "classtable.visual_settings.completed_saturation_factor", + translationParams: { + "value": _formatPercent(completedSaturationFactor), + }, + ), + ), + Slider( + value: completedSaturationFactor, + min: 0.1, + max: 1.0, + divisions: 18, + onChanged: (value) => setDialogState( + () => completedSaturationFactor = value, + ), + ), + Text( + FlutterI18n.translate( + context, + "classtable.visual_settings.completed_brightness_factor", + translationParams: { + "value": _formatPercent(completedBrightnessFactor), + }, + ), + ), + Slider( + value: completedBrightnessFactor, + min: 0.5, + max: 1.0, + divisions: 10, + onChanged: (value) => setDialogState( + () => completedBrightnessFactor = value, + ), + ), + Text( + FlutterI18n.translate( + context, + "classtable.visual_settings.completed_text_saturation_factor", + translationParams: { + "value": _formatPercent( + completedTextSaturationFactor, + ), + }, + ), + ), + Slider( + value: completedTextSaturationFactor, + min: 0.1, + max: 1.0, + divisions: 18, + onChanged: (value) => setDialogState( + () => completedTextSaturationFactor = value, + ), + ), + Text( + FlutterI18n.translate( + context, + "classtable.visual_settings.completed_border_alpha", + translationParams: { + "value": _formatPercent(completedBorderAlpha), + }, + ), + ), + Slider( + value: completedBorderAlpha, + min: 0.1, + max: 1.0, + divisions: 18, + onChanged: (value) => + setDialogState(() => completedBorderAlpha = value), + ), + Text( + FlutterI18n.translate( + context, + "classtable.visual_settings.completed_inner_alpha", + translationParams: { + "value": _formatPercent(completedInnerAlpha), + }, + ), + ), + Slider( + value: completedInnerAlpha, + min: 0.1, + max: 1.0, + divisions: 18, + onChanged: (value) => + setDialogState(() => completedInnerAlpha = value), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(FlutterI18n.translate(context, "cancel")), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(FlutterI18n.translate(context, "confirm")), + ), + ], + ), + ), + ) ?? + false; + + if (!shouldApply || !mounted) { + return; + } + + CompletedClassStyleConfig.activeBrightnessFactor = activeBrightnessFactor + .clamp(0.5, 1.0) + .toDouble(); + CompletedClassStyleConfig.activeBorderAlpha = activeBorderAlpha; + CompletedClassStyleConfig.activeInnerAlpha = activeInnerAlpha; + CompletedClassStyleConfig.completedSaturationFactor = + completedSaturationFactor; + CompletedClassStyleConfig.completedBrightnessFactor = + completedBrightnessFactor.clamp(0.5, 1.0).toDouble(); + CompletedClassStyleConfig.completedTextSaturationFactor = + completedTextSaturationFactor; + CompletedClassStyleConfig.completedBorderAlpha = completedBorderAlpha; + CompletedClassStyleConfig.completedInnerAlpha = completedInnerAlpha; + setState(() {}); + } + @override Widget build(BuildContext context) { final state = ClassTableState.of(context)!.controllers; @@ -327,6 +667,24 @@ class _ContentClassTablePageState extends State { ), ), ), + PopupMenuItem( + value: 'J', + child: Text( + FlutterI18n.translate( + context, + "classtable.popup_menu.current_time_settings", + ), + ), + ), + PopupMenuItem( + value: 'K', + child: Text( + FlutterI18n.translate( + context, + "classtable.popup_menu.class_color_settings", + ), + ), + ), ], onSelected: (String action) async { final box = context.findRenderObject() as RenderBox?; @@ -573,6 +931,13 @@ class _ContentClassTablePageState extends State { } }); } + break; + case 'J': + await _showCurrentTimeSettingsDialog(); + break; + case 'K': + await _showClassColorSettingsDialog(); + break; } }, ), diff --git a/lib/page/classtable/class_page/week_choice_view.dart b/lib/page/classtable/class_page/week_choice_view.dart index d7eab31c..5a32e254 100644 --- a/lib/page/classtable/class_page/week_choice_view.dart +++ b/lib/page/classtable/class_page/week_choice_view.dart @@ -6,6 +6,8 @@ import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/material.dart'; import 'package:flutter_i18n/flutter_i18n.dart'; import 'package:watermeter/page/classtable/class_table_view/class_organized_data.dart'; +import 'package:watermeter/page/classtable/class_table_view/completed_class_style.dart'; +import 'package:watermeter/page/classtable/class_table_view/current_time_indicator.dart'; import 'package:watermeter/page/classtable/classtable_constant.dart'; import 'package:watermeter/page/classtable/classtable_state.dart'; @@ -22,6 +24,55 @@ class _WeekChoiceViewState extends State { late ClassTableWidgetState controller; // 缓存 AutoSizeGroup,避免每次 build 创建新实例 final AutoSizeGroup _autoSizeGroup = AutoSizeGroup(); + static const double _occupiedOpacity = 1.0; + static const double _completedOpacity = 0.45; + static const double _vacantOpacity = 0.25; + + Color _desaturateColor(Color color, {required double factor}) { + final hsl = HSLColor.fromColor(color); + return hsl.withSaturation(hsl.saturation * factor).toColor(); + } + + ({int start, int stop}) _slotRange(int timeIndex) { + switch (timeIndex) { + case 0: + return (start: 0, stop: 10); + case 1: + return (start: 10, stop: 20); + case 2: + return (start: 20, stop: 33); + case 3: + return (start: 33, stop: 43); + default: + // 49 is the visible limit of the compact week preview. + return (start: 46, stop: 49); + } + } + + bool _eventOccupiesSlot({ + required ClassOrgainzedData event, + required int slotStart, + required int slotStop, + }) { + return (event.stop != slotStart && event.start != slotStop) && + ((slotStart < event.stop && event.start < slotStop) || + (slotStop > event.start && event.stop > slotStart)); + } + + bool _slotCompleted({ + required DateTime slotDate, + required DateTime today, + required int slotStop, + required double currentBlockIndex, + }) { + if (slotDate.isBefore(today)) { + return true; + } + if (slotDate.isAfter(today)) { + return false; + } + return slotStop <= currentBlockIndex; + } @override void didChangeDependencies() { @@ -31,11 +82,23 @@ class _WeekChoiceViewState extends State { /// The dot of the overview, [isOccupied] is used to identify the opacity of the dot. /// [primaryColor] is passed in from outside to avoid calling Theme.of(context) for each dot. - Widget dot({required bool isOccupied, required Color primaryColor}) { - double opacity = isOccupied ? 1 : 0.25; - return ClipOval( - child: ColoredBox(color: primaryColor.withValues(alpha: opacity)), - ); + Widget dot({ + required bool isOccupied, + required bool isCompleted, + required Color primaryColor, + }) { + double opacity = _vacantOpacity; + Color dotColor = primaryColor; + if (isOccupied) { + opacity = isCompleted ? _completedOpacity : _occupiedOpacity; + if (isCompleted) { + dotColor = _desaturateColor( + primaryColor, + factor: CompletedClassStyleConfig.completedSaturationFactor, + ); + } + } + return ClipOval(child: ColoredBox(color: dotColor.withValues(alpha: opacity))); } /// [buttonInformaion] shows the botton's [index] and the overview. @@ -79,50 +142,56 @@ class _WeekChoiceViewState extends State { children: List.generate(25, (i) { int day = i % 5 + 1; int time = i ~/ 5; - bool isOccupied = false; + final now = controller.currentTime; + final today = DateTime(now.year, now.month, now.day); + final weekStart = controller.startDay + .add(Duration(days: 7 * controller.offset)) + .add(Duration(days: 7 * widget.index)); + final blockDate = weekStart.add(Duration(days: day - 1)); + final currentBlockIndex = CurrentTimeIndicator + .transferTimeToBlockIndex(now); List arrangedEvents = controller .getArrangement(weekIndex: widget.index, dayIndex: day); - for (var i in arrangedEvents) { - int start = 0; - int stop = 0; - - switch (time) { - case 0: - start = 0; - stop = 10; - break; - case 1: - start = 10; - stop = 20; - isOccupied = i.stop > 10.0 && i.stop <= 20.0; - break; - case 2: - start = 20; - stop = 33; - isOccupied = i.stop > 23.0 && i.stop <= 33.0; - break; - case 3: - start = 33; - stop = 43; - isOccupied = i.stop > 33.0 && i.stop <= 43.0; - break; - case 4: - start = 46; - stop = 49; // 49 is the limit of the classtable... - break; - } + final slot = _slotRange(time); + + var hasOccupiedEvent = false; + var allOccupiedEventsCompleted = true; - if ((i.stop != start && i.start != stop) && - ((start < i.stop && i.start < stop) || - (stop > i.start && i.stop > start))) { - isOccupied = true; + for (var event in arrangedEvents) { + final eventOccupiesCell = _eventOccupiesSlot( + event: event, + slotStart: slot.start, + slotStop: slot.stop, + ); + if (!eventOccupiesCell) { + continue; } - if (isOccupied) break; + hasOccupiedEvent = true; + + final eventCompleted = _slotCompleted( + slotDate: blockDate, + today: today, + slotStop: slot.stop, + currentBlockIndex: currentBlockIndex, + ); + + allOccupiedEventsCompleted = + allOccupiedEventsCompleted && eventCompleted; + + // Any ongoing/upcoming arrangement in the same cell should keep + // the preview block highlighted as active. + if (!eventCompleted) { + break; + } } - return dot(isOccupied: isOccupied, primaryColor: primaryColor); + return dot( + isOccupied: hasOccupiedEvent, + isCompleted: hasOccupiedEvent && allOccupiedEventsCompleted, + primaryColor: primaryColor, + ); }), ), ), diff --git a/lib/page/classtable/class_table_view/class_card.dart b/lib/page/classtable/class_table_view/class_card.dart index 59dbe0a7..be3d28a6 100644 --- a/lib/page/classtable/class_table_view/class_card.dart +++ b/lib/page/classtable/class_table_view/class_card.dart @@ -10,162 +10,219 @@ import 'package:watermeter/model/xidian_ids/exam.dart'; import 'package:watermeter/model/xidian_ids/experiment.dart'; import 'package:watermeter/page/classtable/class_add/class_add_window.dart'; import 'package:watermeter/page/classtable/class_table_view/class_organized_data.dart'; +import 'package:watermeter/page/classtable/class_table_view/completed_class_style.dart'; import 'package:watermeter/page/classtable/arrangement_detail/arrangement_detail.dart'; import 'package:watermeter/page/classtable/classtable_state.dart'; import 'package:watermeter/page/public_widget/both_side_sheet.dart'; import 'package:watermeter/page/public_widget/public_widget.dart'; -/// The card in [classSubRow], metioned in [ClassTableView]. +/// The card in [classSubRow], mentioned in [ClassTableView]. class ClassCard extends StatelessWidget { final ClassOrgainzedData detail; + final double completedHeight; List get data => detail.data; MaterialColor get color => detail.color; String get name => detail.name; String? get place => detail.place; - const ClassCard({super.key, required this.detail}); + const ClassCard({ + super.key, + required this.detail, + required this.completedHeight, + }); @override Widget build(BuildContext context) { - ClassTableWidgetState classTableState = ClassTableState.of( - context, - )!.controllers; + final classTableState = ClassTableState.of(context)!.controllers; + final activeStyle = CompletedClassStyle.resolve( + palette: color, + isCompleted: false, + ); + final completedStyle = CompletedClassStyle.resolve( + palette: color, + isCompleted: true, + ); /// This is the result of the class info card. + const borderRadius = BorderRadius.all(Radius.circular(8)); return Padding( padding: const EdgeInsets.all(1), child: ClipRRect( - // Out - borderRadius: BorderRadius.circular(8), - child: Container( - // Border - color: color.shade300.withValues(alpha: 0.8), - padding: const EdgeInsets.all(2), - child: Stack( - children: [ - ClipRRect( - // Inner - borderRadius: BorderRadius.circular(6), - child: Container( - color: color.shade100.withValues(alpha: 0.7), - child: TextButton( - style: TextButton.styleFrom( - padding: EdgeInsets.zero, - overlayColor: Colors.transparent, + borderRadius: borderRadius, + child: LayoutBuilder( + builder: (context, constraints) { + final splitHeight = completedHeight.clamp( + 0.0, + constraints.maxHeight, + ); + final isCompleted = splitHeight >= constraints.maxHeight - 0.5; + final textStyle = isCompleted ? completedStyle : activeStyle; + final borderStyle = isCompleted ? completedStyle : activeStyle; + + return Stack( + fit: StackFit.expand, + children: [ + if (splitHeight > 0) + Positioned( + top: 0, + left: 0, + right: 0, + height: splitHeight, + child: Container( + color: completedStyle.innerColor.withValues( + alpha: completedStyle.innerAlpha, + ), ), - onPressed: () async { - var controller = ClassTableState.of(context)!.controllers; + ), + if (splitHeight < constraints.maxHeight) + Positioned( + top: splitHeight, + left: 0, + right: 0, + bottom: 0, + child: Container( + color: activeStyle.innerColor.withValues( + alpha: activeStyle.innerAlpha, + ), + ), + ), + TextButton( + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + overlayColor: Colors.transparent, + ), + onPressed: () async { + final controller = ClassTableState.of(context)!.controllers; - /// The way to show the class info of the period. - /// The last one indicate whether to delete this stuff. - (ClassDetail, TimeArrangement, bool)? toUse = - await BothSideSheet.show( - title: FlutterI18n.translate( - context, - "classtable.class_card.title", - ), - child: ArrangementDetail( - information: List.generate(data.length, (index) { - if (data.elementAt(index) is Subject || - data.elementAt(index) is ExperimentData) { - return data.elementAt(index); - } else { - return ( - classTableState.getClassDetail( - classTableState.timeArrangement.indexOf( - data.elementAt(index), - ), - ), - data.elementAt(index), - ); - } - }), - currentWeek: classTableState.currentWeek, - ), - context: context, - ); - if (context.mounted && toUse != null) { - if (toUse.$3) { - await ClassTableState.of( + // Show the class info for this card. + // The last value indicates whether to delete it. + final toUse = + await BothSideSheet.show< + (ClassDetail, TimeArrangement, bool) + >( + title: FlutterI18n.translate( context, - )!.controllers.deleteUserDefinedClass(toUse.$2); - } else { - await Navigator.of(context) - .push( - MaterialPageRoute( - builder: (context) => ClassAddWindow( - toChange: (toUse.$1, toUse.$2), - semesterLength: controller.semesterLength, - ), - ), - ) - .then((value) { - if (value == null) return; - controller.editUserDefinedClass( - value.$1, - value.$2, - value.$3, - ); - }); - } - } - }, - child: - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Flexible( - child: Text( - name, - style: TextStyle( - color: color.shade900, - fontSize: isPhone(context) ? 12 : 14, - ), - maxLines: 3, - overflow: TextOverflow.clip, + "classtable.class_card.title", + ), + child: ArrangementDetail( + information: List.generate(data.length, (index) { + if (data.elementAt(index) is Subject || + data.elementAt(index) is ExperimentData) { + return data.elementAt(index); + } + + final arrangement = data.elementAt(index); + return ( + classTableState.getClassDetail( + classTableState.timeArrangement.indexOf( + arrangement, ), ), - Text( - "@${place ?? FlutterI18n.translate(context, "classtable.class_card.unknown_classroom")}", - style: TextStyle( - color: color.shade900, - fontSize: isPhone(context) ? 10 : 12, - ), + arrangement, + ); + }), + currentWeek: classTableState.currentWeek, + ), + context: context, + ); + if (context.mounted && toUse != null) { + if (toUse.$3) { + await ClassTableState.of( + context, + )!.controllers.deleteUserDefinedClass(toUse.$2); + } else { + await Navigator.of(context) + .push( + MaterialPageRoute( + builder: (context) => ClassAddWindow( + toChange: (toUse.$1, toUse.$2), + semesterLength: controller.semesterLength, ), - if (data.length > 1) - Text( - FlutterI18n.translate( - context, - "classtable.class_card.remains_hint", - translationParams: { - "remain_count": (data.length - 1) - .toString(), - }, - ), - style: TextStyle( - color: color.shade900, - fontSize: isPhone(context) ? 10 : 12, - ), - ), - ], + ), ) - .alignment(Alignment.topLeft) - .padding( - horizontal: isPhone(context) ? 2 : 4, - vertical: 4, + .then((value) { + if (value == null) return; + controller.editUserDefinedClass( + value.$1, + value.$2, + value.$3, + ); + }); + } + } + }, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: isPhone(context) ? 2 : 4, + vertical: 4, + ), + child: Align( + alignment: Alignment.topLeft, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: Text( + name, + style: TextStyle( + color: textStyle.textColor, + fontSize: isPhone(context) ? 12 : 14, + ), + maxLines: 3, + overflow: TextOverflow.clip, + ), + ), + Text( + "@${place ?? FlutterI18n.translate(context, "classtable.class_card.unknown_classroom")}", + style: TextStyle( + color: textStyle.textColor, + fontSize: isPhone(context) ? 10 : 12, ), + ), + if (data.length > 1) + Text( + FlutterI18n.translate( + context, + "classtable.class_card.remains_hint", + translationParams: { + "remain_count": (data.length - 1).toString(), + }, + ), + style: TextStyle( + color: textStyle.textColor, + fontSize: isPhone(context) ? 10 : 12, + ), + ), + ], + ), + ), + ), + ), + if (data.length > 1) + ClipPath( + clipper: Triangle(), + child: Container( + color: textStyle.borderColor, + ).constrained(width: 8, height: 8), + ).alignment(Alignment.topRight), + Positioned.fill( + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: borderRadius, + border: Border.all( + color: borderStyle.borderColor.withValues( + alpha: borderStyle.borderAlpha, + ), + width: 2, + ), + ), + ), ), ), - ), - if (data.length > 1) - ClipPath( - clipper: Triangle(), - child: Container( - color: color.shade300, - ).constrained(width: 8, height: 8), - ).alignment(Alignment.topRight), - ], - ), + ], + ); + }, ), ), ); @@ -175,7 +232,7 @@ class ClassCard extends StatelessWidget { class Triangle extends CustomClipper { @override Path getClip(Size size) { - Path path = Path(); + final path = Path(); path.addPolygon([ const Offset(0, 0), Offset(size.width, 0), diff --git a/lib/page/classtable/class_table_view/class_organized_data.dart b/lib/page/classtable/class_table_view/class_organized_data.dart index a7fc8054..b0e35fed 100644 --- a/lib/page/classtable/class_table_view/class_organized_data.dart +++ b/lib/page/classtable/class_table_view/class_organized_data.dart @@ -6,6 +6,7 @@ // Removed left/right, only use stack. import 'package:flutter/material.dart'; +import 'package:watermeter/model/time_list.dart'; import 'package:watermeter/model/xidian_ids/exam.dart'; import 'package:watermeter/model/xidian_ids/classtable.dart'; import 'package:watermeter/model/xidian_ids/experiment.dart'; @@ -29,27 +30,10 @@ class ClassOrgainzedData { final String name; final String? place; + final DateTime? actualEndTime; final MaterialColor color; - /// Following is the begin/end for each blocks... - static const _timeInBlock = [ - "08:30", - "09:20", - "10:25", - "11:15", - "12:00", - "14:00", - "14:50", - "15:55", - "16:45", - "17:30", - "19:00", - "19:55", - "20:35", - "21:25", - ]; - factory ClassOrgainzedData.fromTimeArrangement( TimeArrangement timeArrangement, MaterialColor color, @@ -80,6 +64,7 @@ class ClassOrgainzedData { color: color, name: name, place: timeArrangement.classroom, + actualEndTime: null, ); } @@ -96,6 +81,7 @@ class ClassOrgainzedData { place: "${subject.place} " "${subject.seat == null ? "" : "${subject.seat}"}", + actualEndTime: subject.stopTime, ); factory ClassOrgainzedData.fromExperiment( @@ -110,6 +96,7 @@ class ClassOrgainzedData { color: color, name: exp.name, place: exp.classroom, + actualEndTime: stop, ); ClassOrgainzedData({ @@ -119,43 +106,65 @@ class ClassOrgainzedData { required this.name, required this.color, this.place, + this.actualEndTime, }); static double _transferIndex(DateTime time) { - int timeInMin = time.hour * 60 + time.minute; - int previous = 0; - // Start from the second element. - for (var i in _timeInBlock) { - int timeChosen = - int.parse(i.split(":")[0]) * 60 + int.parse(i.split(":")[1]); - if (previous == 0) { - // Some exam is started before 8:30 - if (timeInMin < timeChosen) { - return 0; + final timeInMin = time.hour * 60 + time.minute; + if (timeList.isEmpty) { + return 0; + } + + int parseMinute(String hhmm) { + final parts = hhmm.split(':'); + return int.parse(parts[0]) * 60 + int.parse(parts[1]); + } + + double classStartBlock(int classIndex) { + if (classIndex < 4) { + return classIndex * 5.0; + } + if (classIndex < 8) { + return 23 + (classIndex - 4) * 5.0; + } + return 46 + (classIndex - 8) * 5.0; + } + + final firstStart = parseMinute(timeList.first); + if (timeInMin < firstStart) { + return 0; + } + + final classCount = timeList.length ~/ 2; + for (var classIndex = 0; classIndex < classCount; classIndex++) { + final startMinute = parseMinute(timeList[classIndex * 2]); + final endMinute = parseMinute(timeList[classIndex * 2 + 1]); + final startBlock = classStartBlock(classIndex); + final endBlock = startBlock + 5; + + if (timeInMin >= startMinute && timeInMin < endMinute) { + final ratio = (timeInMin - startMinute) / (endMinute - startMinute); + return startBlock + 5 * ratio; + } + + if (classIndex == classCount - 1) { + if (timeInMin >= endMinute) { + return 61; } - previous = timeChosen; continue; } - if (timeInMin >= previous && timeInMin < timeChosen) { - double basic = 0; - double blocks = 5; - double ratio = (timeInMin - previous) / (timeChosen - previous); - if (previous < 12 * 60) { - basic = (_timeInBlock.indexOf(i) - 1) * 5; - } else if (previous < 14 * 60) { - basic = 20; - blocks = 3; - } else if (previous < 17.5 * 60) { - basic = 23 + (_timeInBlock.indexOf(i) - 6) * 5; - } else if (previous < 19 * 60) { - basic = 43; - blocks = 3; - } else { - basic = 46 + (_timeInBlock.indexOf(i) - 11) * 5; + + final nextStartMinute = parseMinute(timeList[(classIndex + 1) * 2]); + if (timeInMin >= endMinute && timeInMin < nextStartMinute) { + final nextStartBlock = classStartBlock(classIndex + 1); + final breakMinuteSpan = nextStartMinute - endMinute; + final breakBlockSpan = nextStartBlock - endBlock; + + if (breakMinuteSpan > 0 && breakBlockSpan > 0) { + final ratio = (timeInMin - endMinute) / breakMinuteSpan; + return endBlock + breakBlockSpan * ratio; } - return basic + blocks * ratio; - } else { - previous = timeChosen; + return endBlock; } } @@ -169,6 +178,7 @@ class ClassOrgainzedData { required this.color, required this.name, this.place, + this.actualEndTime, }) { this.start = _transferIndex(start); this.stop = _transferIndex(stop); diff --git a/lib/page/classtable/class_table_view/class_table_view.dart b/lib/page/classtable/class_table_view/class_table_view.dart index c2d0c252..ac9200ec 100644 --- a/lib/page/classtable/class_table_view/class_table_view.dart +++ b/lib/page/classtable/class_table_view/class_table_view.dart @@ -10,6 +10,7 @@ import 'package:watermeter/model/time_list.dart'; import 'package:watermeter/page/classtable/class_table_view/class_card.dart'; import 'package:watermeter/page/classtable/class_table_view/class_organized_data.dart'; import 'package:watermeter/page/classtable/class_table_view/classtable_date_row.dart'; +import 'package:watermeter/page/classtable/class_table_view/current_time_indicator.dart'; import 'package:watermeter/page/classtable/classtable_constant.dart'; import 'package:watermeter/page/classtable/classtable_state.dart'; import 'package:watermeter/page/public_widget/public_widget.dart'; @@ -42,6 +43,53 @@ class ClassTableView extends StatefulWidget { class _ClassTableViewState extends State { late ClassTableWidgetState classTableState; late BoxConstraints size; + ClassTableWidgetState? _attachedClassTableState; + + DateTime get _visibleWeekStart => classTableState.startDay + .add(Duration(days: 7 * classTableState.offset)) + .add(Duration(days: 7 * widget.index)); + + double _completedHeight(ClassOrgainzedData data, int dayIndex) { + final now = classTableState.currentTime; + final dayStart = _visibleWeekStart.add(Duration(days: dayIndex - 1)); + final today = DateTime(now.year, now.month, now.day); + + if (today.isBefore(dayStart)) { + return 0; + } + if (today.isAfter(dayStart)) { + return blockheight(data.stop - data.start); + } + + final currentIndex = CurrentTimeIndicator.transferTimeToBlockIndex(now); + if (currentIndex <= data.start) { + return 0; + } + + final completedBlocks = (currentIndex - data.start).clamp( + 0.0, + data.stop - data.start, + ); + return blockheight(completedBlocks); + } + + Positioned? _currentTimeIndicator() => CurrentTimeIndicator.build( + context: context, + now: classTableState.currentTime, + weekStart: _visibleWeekStart, + leftRow: leftRow, + blockWidth: blockwidth, + blockHeight: blockheight, + ); + + Positioned? _currentDayColumnBox() => CurrentTimeIndicator.buildDayColumnBox( + context: context, + now: classTableState.currentTime, + weekStart: _visibleWeekStart, + leftRow: leftRow, + blockWidth: blockwidth, + blockHeight: blockheight, + ); /// The height of the class card. double blockheight(double count) => @@ -55,6 +103,11 @@ class _ClassTableViewState extends State { List classSubRow(bool isRest) { if (isRest) { List thisRow = []; + final currentDayColumnBox = _currentDayColumnBox(); + if (currentDayColumnBox != null) { + thisRow.add(currentDayColumnBox); + } + for (var index = 1; index <= 7; ++index) { List arrangedEvents = classTableState .getArrangement(weekIndex: widget.index, dayIndex: index); @@ -62,18 +115,24 @@ class _ClassTableViewState extends State { /// Choice the day and render it! for (var i in arrangedEvents) { /// Generate the row. + final completedHeight = _completedHeight(i, index); thisRow.add( Positioned( top: blockheight(i.start), height: blockheight(i.stop - i.start), left: leftRow + blockwidth * (index - 1), width: blockwidth, - child: ClassCard(detail: i), + child: ClassCard(detail: i, completedHeight: completedHeight), ), ); } } + final timeIndicator = _currentTimeIndicator(); + if (timeIndicator != null) { + thisRow.add(timeIndicator); + } + if (thisRow.isEmpty && !preference.getBool(preference.Preference.decorated)) { thisRow.add( @@ -169,14 +228,21 @@ class _ClassTableViewState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); - classTableState = ClassTableState.of(context)!.controllers; - classTableState.addListener(_reload); + final nextClassTableState = ClassTableState.of(context)!.controllers; + + if (_attachedClassTableState != nextClassTableState) { + _attachedClassTableState?.removeListener(_reload); + _attachedClassTableState = nextClassTableState; + _attachedClassTableState!.addListener(_reload); + } + + classTableState = nextClassTableState; updateSize(); } @override void dispose() { - classTableState.removeListener(_reload); + _attachedClassTableState?.removeListener(_reload); super.dispose(); } diff --git a/lib/page/classtable/class_table_view/completed_class_style.dart b/lib/page/classtable/class_table_view/completed_class_style.dart new file mode 100644 index 00000000..1db24e02 --- /dev/null +++ b/lib/page/classtable/class_table_view/completed_class_style.dart @@ -0,0 +1,143 @@ +// Copyright 2025 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 OR Apache-2.0 + +import 'package:flutter/material.dart'; +import 'package:watermeter/model/time_list.dart'; +import 'package:watermeter/model/xidian_ids/classtable.dart'; +import 'package:watermeter/page/classtable/class_table_view/class_organized_data.dart'; + +class CompletedClassStyleConfig { + /// Completed-card color tuning. + /// Lower values make finished classes look more muted and faded. + static double completedSaturationFactor = 0.25; + static double completedBrightnessFactor = 0.75; + static double completedTextSaturationFactor = 0.75; + static double completedBorderAlpha = 0.75; + static double completedInnerAlpha = 0.75; + + /// Active-card baseline appearance. + static double activeBrightnessFactor = 1.0; + static double activeBorderAlpha = 1.0; + static double activeInnerAlpha = 0.9; +} + +class CompletedClassStyleData { + final Color borderColor; + final Color innerColor; + final Color textColor; + final double borderAlpha; + final double innerAlpha; + + const CompletedClassStyleData({ + required this.borderColor, + required this.innerColor, + required this.textColor, + required this.borderAlpha, + required this.innerAlpha, + }); +} + +class CompletedClassStyle { + static Color _desaturateColor(Color color, {double factor = 0.35}) { + final hsl = HSLColor.fromColor(color); + return hsl.withSaturation(hsl.saturation * factor).toColor(); + } + + static Color _adjustBrightness(Color color, {double factor = 1.0}) { + final hsl = HSLColor.fromColor(color); + final brightness = (hsl.lightness * factor).clamp(0.0, 1.0).toDouble(); + return hsl.withLightness(brightness).toColor(); + } + + static Color _tuneColor( + Color color, { + required double saturationFactor, + required double brightnessFactor, + }) { + final desaturated = _desaturateColor(color, factor: saturationFactor); + return _adjustBrightness(desaturated, factor: brightnessFactor); + } + + static CompletedClassStyleData resolve({ + required MaterialColor palette, + required bool isCompleted, + }) { + final saturationFactor = isCompleted + ? CompletedClassStyleConfig.completedSaturationFactor + : 1.0; + final brightnessFactor = + (isCompleted + ? CompletedClassStyleConfig.completedBrightnessFactor + : CompletedClassStyleConfig.activeBrightnessFactor) + .clamp(0.5, 1.0) + .toDouble(); + final borderColor = isCompleted + ? _tuneColor( + palette.shade300, + saturationFactor: saturationFactor, + brightnessFactor: brightnessFactor, + ) + : _adjustBrightness(palette.shade300, factor: brightnessFactor); + final innerColor = isCompleted + ? _tuneColor( + palette.shade100, + saturationFactor: saturationFactor, + brightnessFactor: brightnessFactor, + ) + : _adjustBrightness(palette.shade100, factor: brightnessFactor); + final textColor = isCompleted + ? _tuneColor( + palette.shade900, + saturationFactor: + CompletedClassStyleConfig.completedTextSaturationFactor, + brightnessFactor: brightnessFactor, + ) + : _adjustBrightness(palette.shade900, factor: brightnessFactor); + + return CompletedClassStyleData( + borderColor: borderColor, + innerColor: innerColor, + textColor: textColor, + borderAlpha: isCompleted + ? CompletedClassStyleConfig.completedBorderAlpha + : CompletedClassStyleConfig.activeBorderAlpha, + innerAlpha: isCompleted + ? CompletedClassStyleConfig.completedInnerAlpha + : CompletedClassStyleConfig.activeInnerAlpha, + ); + } + + static DateTime eventEndTime({ + required ClassOrgainzedData data, + required DateTime dayStart, + }) { + if (data.actualEndTime != null) { + return data.actualEndTime!; + } + + if (data.data.isEmpty || data.data.first is! TimeArrangement) { + return dayStart; + } + + final arrangement = data.data.first as TimeArrangement; + final stopIndex = (arrangement.stop - 1) * 2 + 1; + final stopTime = timeList[stopIndex].split(':'); + + return DateTime( + dayStart.year, + dayStart.month, + dayStart.day, + int.parse(stopTime[0]), + int.parse(stopTime[1]), + ); + } + + static bool isCompleted({ + required ClassOrgainzedData data, + required DateTime now, + required DateTime dayStart, + }) { + final end = eventEndTime(data: data, dayStart: dayStart); + return end.isBefore(now); + } +} diff --git a/lib/page/classtable/class_table_view/current_time_indicator.dart b/lib/page/classtable/class_table_view/current_time_indicator.dart new file mode 100644 index 00000000..8d969da3 --- /dev/null +++ b/lib/page/classtable/class_table_view/current_time_indicator.dart @@ -0,0 +1,269 @@ +// Copyright 2025 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 OR Apache-2.0 + +import 'package:flutter/material.dart'; +import 'package:watermeter/model/time_list.dart'; + +class CurrentTimeIndicatorConfig { + static bool enabled = true; + static bool showTimeLabel = true; + static bool showTodayColumnHighlight = true; + static double lineAlpha = 0.9; + static double lineThickness = 2; + static double labelHeight = 13; + static double labelFontSize = 8; + static double labelBackgroundAlpha = 0.75; + static double labelHorizontalPadding = 1; + static double labelVerticalPadding = 1; + static double labelBorderRadius = 4; + static double dayColumnHighlightAlpha = 0.25; + static double dayColumnHighlightRadius = 8; +} + +class CurrentTimeIndicator { + static double _transferIndex(DateTime time) { + final timeInMin = time.hour * 60 + time.minute; + if (timeList.isEmpty) { + return 0; + } + + int parseMinute(String hhmm) { + final parts = hhmm.split(':'); + return int.parse(parts[0]) * 60 + int.parse(parts[1]); + } + + double classStartBlock(int classIndex) { + if (classIndex < 4) { + return classIndex * 5.0; + } + if (classIndex < 8) { + return 23 + (classIndex - 4) * 5.0; + } + return 46 + (classIndex - 8) * 5.0; + } + + final firstStart = parseMinute(timeList.first); + if (timeInMin < firstStart) { + return 0; + } + + final classCount = timeList.length ~/ 2; + for (var classIndex = 0; classIndex < classCount; classIndex++) { + final startMinute = parseMinute(timeList[classIndex * 2]); + final endMinute = parseMinute(timeList[classIndex * 2 + 1]); + final startBlock = classStartBlock(classIndex); + final endBlock = startBlock + 5; + + if (timeInMin >= startMinute && timeInMin < endMinute) { + final ratio = (timeInMin - startMinute) / (endMinute - startMinute); + return startBlock + 5 * ratio; + } + + if (classIndex == classCount - 1) { + if (timeInMin >= endMinute) { + return 61; + } + continue; + } + + final nextStartMinute = parseMinute(timeList[(classIndex + 1) * 2]); + if (timeInMin >= endMinute && timeInMin < nextStartMinute) { + final nextStartBlock = classStartBlock(classIndex + 1); + final breakMinuteSpan = nextStartMinute - endMinute; + final breakBlockSpan = nextStartBlock - endBlock; + + // Move continuously during breaks that have visible rows (e.g. lunch/dinner). + // If there is no visual gap between classes, keep the indicator at the boundary. + if (breakMinuteSpan > 0 && breakBlockSpan > 0) { + final ratio = (timeInMin - endMinute) / breakMinuteSpan; + return endBlock + breakBlockSpan * ratio; + } + return endBlock; + } + } + + return 61; + } + + static double transferTimeToBlockIndex(DateTime time) => _transferIndex(time); + + static Positioned? build({ + required BuildContext context, + required DateTime now, + required DateTime weekStart, + required double leftRow, + required double blockWidth, + required double Function(double) blockHeight, + }) { + if (!CurrentTimeIndicatorConfig.enabled) { + return null; + } + + final today = DateTime(now.year, now.month, now.day); + final normalizedWeekStart = DateTime( + weekStart.year, + weekStart.month, + weekStart.day, + ); + final dayOffset = today.difference(normalizedWeekStart).inDays; + if (dayOffset < 0 || dayOffset > 6) { + return null; + } + + final lineTop = blockHeight(_transferIndex(now)); + final colorScheme = Theme.of(context).colorScheme; + final color = colorScheme.primary; + final labelText = + '${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}'; + final hasLabel = CurrentTimeIndicatorConfig.showTimeLabel; + final labelHeight = CurrentTimeIndicatorConfig.labelHeight; + final indicatorTop = lineTop > labelHeight ? lineTop - labelHeight : 0.0; + final lineOffset = lineTop - indicatorTop; + final lineTopOffset = + lineOffset - CurrentTimeIndicatorConfig.lineThickness / 2; + final labelTop = (lineOffset - CurrentTimeIndicatorConfig.labelHeight / 2) + .clamp(0.0, double.infinity) + .toDouble(); + final labelBottom = labelTop + CurrentTimeIndicatorConfig.labelHeight; + final lineBottom = lineTopOffset + CurrentTimeIndicatorConfig.lineThickness; + final indicatorHeight = + (hasLabel + ? (labelBottom > lineBottom ? labelBottom : lineBottom) + : lineBottom) + .clamp(0.0, double.infinity) + .toDouble(); + final labelBackgroundColor = colorScheme.surface.withValues( + alpha: CurrentTimeIndicatorConfig.labelBackgroundAlpha, + ); + final connectorColor = color.withValues( + alpha: CurrentTimeIndicatorConfig.lineAlpha * 0.35, + ); + final lineColor = color.withValues( + alpha: CurrentTimeIndicatorConfig.lineAlpha, + ); + + return Positioned( + left: 0, + top: indicatorTop, + width: leftRow + blockWidth * (dayOffset + 1), + child: IgnorePointer( + child: SizedBox( + height: indicatorHeight, + child: Stack( + children: [ + if (hasLabel) + Positioned( + top: labelTop, + left: 0, + width: leftRow, + height: CurrentTimeIndicatorConfig.labelHeight, + child: DecoratedBox( + decoration: BoxDecoration( + color: labelBackgroundColor, + border: Border.all( + color: color.withValues(alpha: 0.7), + width: 1.4, + ), + borderRadius: BorderRadius.circular( + CurrentTimeIndicatorConfig.labelBorderRadius, + ), + ), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: + CurrentTimeIndicatorConfig.labelHorizontalPadding, + vertical: + CurrentTimeIndicatorConfig.labelVerticalPadding, + ), + child: Center( + child: Text( + labelText, + style: TextStyle( + fontSize: CurrentTimeIndicatorConfig.labelFontSize, + color: color, + fontWeight: FontWeight.w700, + height: 1, + shadows: const [ + Shadow( + offset: Offset(0, 0), + blurRadius: 2, + color: Colors.black26, + ), + ], + ), + ), + ), + ), + ), + ), + if (dayOffset > 0) + Positioned( + top: lineTopOffset, + left: leftRow, + width: blockWidth * dayOffset, + child: Container( + height: CurrentTimeIndicatorConfig.lineThickness, + color: connectorColor, + ), + ), + Positioned( + top: lineTopOffset, + left: leftRow + blockWidth * dayOffset, + width: blockWidth, + child: Container( + height: CurrentTimeIndicatorConfig.lineThickness, + color: lineColor, + ), + ), + ], + ), + ), + ), + ); + } + + static Positioned? buildDayColumnBox({ + required BuildContext context, + required DateTime now, + required DateTime weekStart, + required double leftRow, + required double blockWidth, + required double Function(double) blockHeight, + }) { + if (!CurrentTimeIndicatorConfig.showTodayColumnHighlight) { + return null; + } + + final today = DateTime(now.year, now.month, now.day); + final normalizedWeekStart = DateTime( + weekStart.year, + weekStart.month, + weekStart.day, + ); + final dayOffset = today.difference(normalizedWeekStart).inDays; + if (dayOffset < 0 || dayOffset > 6) { + return null; + } + + final color = Theme.of(context).colorScheme.primary.withValues( + alpha: CurrentTimeIndicatorConfig.dayColumnHighlightAlpha, + ); + + return Positioned( + left: leftRow + blockWidth * dayOffset, + top: 0, + width: blockWidth, + height: blockHeight(61), + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular( + CurrentTimeIndicatorConfig.dayColumnHighlightRadius, + ), + ), + ), + ), + ); + } +} diff --git a/lib/page/classtable/classtable_state.dart b/lib/page/classtable/classtable_state.dart index baef3978..4c9f8f0c 100644 --- a/lib/page/classtable/classtable_state.dart +++ b/lib/page/classtable/classtable_state.dart @@ -2,6 +2,7 @@ // Copyright 2025 Traintime PDA authors. // SPDX-License-Identifier: MPL-2.0 OR Apache-2.0 +import 'dart:async'; import 'dart:math' as math; import 'package:device_calendar/device_calendar.dart'; @@ -62,10 +63,13 @@ class ClassTableWidgetState with ChangeNotifier { ///*******************************************************************/// bool _disposed = false; final List _effectCleanup = []; + Timer? _clockTimer; + DateTime _currentTime = DateTime.now(); @override void dispose() { _disposed = true; + _clockTimer?.cancel(); for (final cleanup in _effectCleanup) { cleanup(); } @@ -92,7 +96,15 @@ class ClassTableWidgetState with ChangeNotifier { OtherExperimentController.i; final WeekSwiftController weekSwiftController = WeekSwiftController.i; + void _initClockTimer() { + _clockTimer = Timer.periodic(const Duration(seconds: 15), (_) { + _currentTime = DateTime.now(); + notifyListeners(); + }); + } + void _initEffects() { + _initClockTimer(); _effectCleanup.add( effect(() { classTableController.schoolClassTableStateSignal.value; @@ -138,6 +150,8 @@ class ClassTableWidgetState with ChangeNotifier { String get decorationName => ClassTableController.decorationName; + DateTime get currentTime => _currentTime; + ///*****************************/// /// Following are dynamic data. /// /// ****************************/// diff --git a/pubspec.lock b/pubspec.lock index f3ca506f..7e2d154e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -785,10 +785,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -1430,10 +1430,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.10" tflite_flutter: dependency: "direct main" description: