diff --git a/README.md b/README.md index c9d5025..4f91329 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Riverside Schedule -电子科技大学研究生课表 APP,Flutter 实现,Android 0.2.0+2,Android 7.0 及以上。 +电子科技大学研究生课表 APP,Flutter 实现,Android 0.2.1+3,Android 7.0 及以上。 -课表周一至周日固定七列同屏,无横向滚动;全表使用统一节次行高,同节数课程条等长。课程名最多两行,可省略,老师和教室完整换行。点击课程查看全称、老师、教室、节次、日期和上课周次。时间冲突用整列宽度的冲突列表展示,列表各项标注节次,避免窄列裁字。 +课表周一至周日固定七列同屏,无横向滚动;课程条默认长度为125%,可在「我的 → 课表设置 → 课程条长度」调节为80%–200%,也可恢复默认;全表使用统一节次行高,同节数课程条等长。课程名最多两行,可省略,老师和教室完整换行。点击课程查看全称、老师、教室、节次、日期和上课周次。时间冲突用整列宽度的冲突列表展示,列表各项标注节次,避免窄列裁字。 -底部只有日历和我的两个图标,玻璃风格悬浮 Dock 支持点击图标、拖动选中游标切换。页面本身不支持左右滑动切换。配色、课前提醒、手机日历和学期设置延续首版。 +底部只有日历和我的两个图标,透明液态玻璃风格悬浮 Dock,主题为蓝色,支持点击图标、拖动选中游标切换。页面本身不支持左右滑动切换。配色、课前提醒、手机日历和学期设置延续首版。 ## 安装和导入 @@ -33,4 +33,4 @@ flutter analyze 仓库根目录运行 `./scripts/build-android.ps1` 构建,成功后复制 APK 到 `dist/`。 -验证记录:[0.2.0改版验证](docs/riverside-revision-validation.md)。Windows模拟器如遇图形驱动崩溃,可仅在测试启动时使用软件渲染并关闭Impeller;交付APK保留默认手机渲染配置。首版通知和日历原生验证保存在 [首版验证记录](docs/android-mvp-validation.md)。 +验证记录:[0.2.1玻璃和长度设置](docs/blue-glass-and-course-length.md)、[0.2.0课表改版验证](docs/riverside-revision-validation.md)。Windows模拟器如遇图形驱动崩溃,可仅在测试启动时使用软件渲染并关闭Impeller;交付APK保留默认手机渲染配置。首版通知和日历原生验证保存在 [首版验证记录](docs/android-mvp-validation.md)。 diff --git a/docs/blue-glass-and-course-length.md b/docs/blue-glass-and-course-length.md new file mode 100644 index 0000000..5439c94 --- /dev/null +++ b/docs/blue-glass-and-course-length.md @@ -0,0 +1,7 @@ +# 0.2.1 透明蓝色Dock和课程条长度 + +2026-10-13:Dock底座及选中游标改成低透明度白色/蓝色玻璃,移除黑灰色渐变和黑色投影;底座与游标使用极低填充透明度和轻微背景模糊;通过双层光学边缘、方向性高光与游标拖动形变表现玻璃厚度,避免磨砂填充。保留图标点击和游标拖动。 + +新增「我的 → 课表设置 → 课程条长度」:默认125%,可调80%–200%,松开滑块后保存,一键恢复默认。统一调整节次行高,相同节数课程条等长;老师和教室的完整显示优先于过短的设置。提醒时间和日历日程不因外观调节而重新生成。 + +新增测试覆盖旧数据默认值迁移、非法长度拒绝、实际文件保存后重读、滑块调节/控制器重开、条形实际变长且等长、七天同屏及不触发通知/日历重排。完整79项测试通过,静态检查无问题。安卓外观与设置保存验证截图位于dist/screenshots。 diff --git a/mobile/lib/app/app_controller.dart b/mobile/lib/app/app_controller.dart index 7f770db..5ed433f 100644 --- a/mobile/lib/app/app_controller.dart +++ b/mobile/lib/app/app_controller.dart @@ -88,18 +88,31 @@ class AppController extends ChangeNotifier { Future updateSettings( AppSettings Function(AppSettings current) change, ) => _run(() async { - final value = change(snapshot.settings); + final previous = snapshot.settings; + final value = change(previous); if (![5, 10, 15, 30, 60].contains(value.reminderMinutes)) { throw ArgumentError('提醒时间无效'); } + if (!value.courseHeightScale.isFinite || + value.courseHeightScale < AppSettings.minCourseHeightScale || + value.courseHeightScale > AppSettings.maxCourseHeightScale) { + throw ArgumentError('课程条长度无效'); + } await _save(snapshot.copyWith(settings: value)); if (isDemo) { _demoSettings = _demoSettings!.copyWith( colorMode: value.colorMode, singleColorArgb: value.singleColorArgb, + courseHeightScale: value.courseHeightScale, ); } - await _reconcile(); + final scheduleChanged = + previous.semester != value.semester || + previous.remindersEnabled != value.remindersEnabled || + previous.reminderMinutes != value.reminderMinutes || + previous.calendarEnabled != value.calendarEnabled || + previous.calendarId != value.calendarId; + if (scheduleChanged) await _reconcile(); }); Future applyImport( ImportDraft draft, { diff --git a/mobile/lib/domain/app_settings.dart b/mobile/lib/domain/app_settings.dart index 47450bc..2a9c5b8 100644 --- a/mobile/lib/domain/app_settings.dart +++ b/mobile/lib/domain/app_settings.dart @@ -11,7 +11,12 @@ class AppSettings { this.singleColorArgb = 0xFF348F91, this.calendarEnabled = false, this.calendarId, + this.courseHeightScale = defaultCourseHeightScale, }); + static const defaultCourseHeightScale = 1.25; + static const minCourseHeightScale = 0.8; + static const maxCourseHeightScale = 2.0; + final double courseHeightScale; final Semester? semester; final bool remindersEnabled, calendarEnabled; final int reminderMinutes, singleColorArgb; @@ -25,6 +30,7 @@ class AppSettings { int? singleColorArgb, bool? calendarEnabled, String? calendarId, + double? courseHeightScale, }) => AppSettings( semester: semester ?? this.semester, remindersEnabled: remindersEnabled ?? this.remindersEnabled, @@ -33,5 +39,6 @@ class AppSettings { singleColorArgb: singleColorArgb ?? this.singleColorArgb, calendarEnabled: calendarEnabled ?? this.calendarEnabled, calendarId: calendarId ?? this.calendarId, + courseHeightScale: courseHeightScale ?? this.courseHeightScale, ); } diff --git a/mobile/lib/domain/app_snapshot.dart b/mobile/lib/domain/app_snapshot.dart index c366f04..bfd45fd 100644 --- a/mobile/lib/domain/app_snapshot.dart +++ b/mobile/lib/domain/app_snapshot.dart @@ -62,6 +62,7 @@ class AppSnapshot { 'singleColorArgb': settings.singleColorArgb, 'calendarEnabled': settings.calendarEnabled, 'calendarId': settings.calendarId, + 'courseHeightScale': settings.courseHeightScale, }, 'importMetadata': importMetadata == null ? null @@ -90,6 +91,15 @@ class AppSnapshot { } final color = s['singleColorArgb'] as int; if (color < 0 || color > 0xFFFFFFFF) throw const FormatException('颜色无效'); + final rawHeight = s.containsKey('courseHeightScale') + ? s['courseHeightScale'] + : AppSettings.defaultCourseHeightScale; + if (rawHeight is! num || + !rawHeight.isFinite || + rawHeight < AppSettings.minCourseHeightScale || + rawHeight > AppSettings.maxCourseHeightScale) { + throw const FormatException('课程条长度无效'); + } final sem = s['semester']; final term = sem == null ? null @@ -110,6 +120,7 @@ class AppSnapshot { singleColorArgb: color, calendarEnabled: s['calendarEnabled'] as bool, calendarId: s['calendarId'] as String?, + courseHeightScale: rawHeight.toDouble(), ), importMetadata: metadata == null ? null diff --git a/mobile/lib/features/profile/settings_page.dart b/mobile/lib/features/profile/settings_page.dart index aea1cef..31290ec 100644 --- a/mobile/lib/features/profile/settings_page.dart +++ b/mobile/lib/features/profile/settings_page.dart @@ -17,6 +17,8 @@ class SettingsPage extends StatefulWidget { class _SettingsPageState extends State { late TextEditingController _weeks; DateTime? _start; + double? _courseHeightDraft; + bool _savingHeight = false; String? _weeksError, _dateError; @override void initState() { @@ -100,6 +102,21 @@ class _SettingsPageState extends State { } } + Future _saveCourseHeight(double value) async { + setState(() => _savingHeight = true); + await widget.controller.updateSettings( + (current) => current.copyWith(courseHeightScale: value), + ); + if (!mounted) return; + setState(() { + _courseHeightDraft = null; + _savingHeight = false; + }); + if (widget.controller.lastError != null) { + _notice(widget.controller.lastError!); + } + } + void _notice(String text) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); @override @@ -203,6 +220,68 @@ class _SettingsPageState extends State { ), ), const SizedBox(height: 22), + _heading('课程条长度'), + SurfaceCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + '${((_courseHeightDraft ?? s.courseHeightScale) * 100).round()}%', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: river, + ), + ), + ), + TextButton( + onPressed: _savingHeight + ? null + : () => _saveCourseHeight( + AppSettings.defaultCourseHeightScale, + ), + child: const Text('恢复默认'), + ), + ], + ), + Slider( + key: const Key('course-height-slider'), + value: _courseHeightDraft ?? s.courseHeightScale, + min: AppSettings.minCourseHeightScale, + max: AppSettings.maxCourseHeightScale, + divisions: 24, + label: + '${((_courseHeightDraft ?? s.courseHeightScale) * 100).round()}%', + semanticFormatterCallback: (value) => + '${(value * 100).round()}%', + onChanged: _savingHeight + ? null + : (value) => setState(() => _courseHeightDraft = value), + onChangeEnd: _saveCourseHeight, + ), + const Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('较短', style: TextStyle(fontSize: 11)), + Text('较长', style: TextStyle(fontSize: 11)), + ], + ), + const SizedBox(height: 10), + Text( + '统一调整课程条长度,老师和教室始终完整显示。', + style: TextStyle( + fontSize: 11, + height: 1.6, + color: ink.withValues(alpha: 0.55), + ), + ), + ], + ), + ), + const SizedBox(height: 22), _heading('课前提醒'), SurfaceCard( child: Column( diff --git a/mobile/lib/features/timetable/week_grid.dart b/mobile/lib/features/timetable/week_grid.dart index 9a01772..a186c5d 100644 --- a/mobile/lib/features/timetable/week_grid.dart +++ b/mobile/lib/features/timetable/week_grid.dart @@ -74,7 +74,7 @@ class WeekGrid extends StatelessWidget { // A single shared lesson height keeps equal-duration cards identical in size. // Increase that height for full metadata, never stretch one card independently. - var rowHeight = 54.0 * scale.clamp(1, 2.5); + var rowHeight = 54.0 * settings.courseHeightScale * scale.clamp(1, 2.5); for (final e in events) { final group = groups[e]!; if (group.first != e) continue; diff --git a/mobile/lib/widgets/glass_dock.dart b/mobile/lib/widgets/glass_dock.dart index 38cd657..bc46cf4 100644 --- a/mobile/lib/widgets/glass_dock.dart +++ b/mobile/lib/widgets/glass_dock.dart @@ -1,4 +1,5 @@ -import 'dart:ui'; +import 'dart:math' as math; +import 'dart:ui' as ui; import 'package:flutter/material.dart'; @@ -16,6 +17,7 @@ class GlassDock extends StatefulWidget { class _GlassDockState extends State { static const _width = 192.0, _travel = 88.0; + static const _blue = Color(0xFF007AFF); double? _drag; double get _position => _drag ?? widget.selectedIndex.toDouble(); void _select(int index) { @@ -23,137 +25,83 @@ class _GlassDockState extends State { widget.onSelected(index); } - @override - Widget build(BuildContext context) => Container( - width: _width, - height: 70, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(40), - boxShadow: [ - BoxShadow( - color: const Color(0xFF10212E).withValues(alpha: 0.22), - blurRadius: 28, - offset: const Offset(0, 10), - ), - BoxShadow( - color: Colors.white.withValues(alpha: 0.35), - blurRadius: 1, - spreadRadius: 1, - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(40), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 24, sigmaY: 24), + Widget _glass({required Widget child, bool lens = false}) => ClipRRect( + borderRadius: BorderRadius.circular(lens ? 36 : 40), + child: BackdropFilter( + // Keep the underlying timetable visible. Optical rims provide the glass + // thickness instead of a frosted, heavily tinted fill. + filter: ui.ImageFilter.blur( + sigmaX: lens ? 0.6 : 1.5, + sigmaY: lens ? 0.6 : 1.5, + ), + child: CustomPaint( + foregroundPainter: _GlassRimPainter(lens: lens), child: DecoratedBox( decoration: BoxDecoration( - borderRadius: BorderRadius.circular(40), gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [ - const Color(0xFFB9C0CA).withValues(alpha: 0.58), - const Color(0xFF56616D).withValues(alpha: 0.34), - const Color(0xFFA1ADB8).withValues(alpha: 0.42), - ], - ), - border: Border.all( - color: Colors.white.withValues(alpha: 0.55), - width: 1.2, + colors: lens + ? [ + Colors.white.withValues(alpha: 0.045), + _blue.withValues(alpha: 0.018), + Colors.white.withValues(alpha: 0.012), + ] + : [ + Colors.white.withValues(alpha: 0.028), + _blue.withValues(alpha: 0.005), + Colors.white.withValues(alpha: 0.012), + ], ), ), - child: Stack( - children: [ - Positioned( - left: 18, - right: 18, - top: 1, - child: Container( - height: 1.5, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(30), - gradient: LinearGradient( - colors: [ - Colors.white.withValues(alpha: 0), - Colors.white.withValues(alpha: 0.8), - Colors.white.withValues(alpha: 0.08), - ], - ), - ), - ), - ), - AnimatedPositioned( - duration: Duration(milliseconds: _drag == null ? 260 : 0), - curve: Curves.easeOutCubic, - left: 7 + _position * _travel, - top: _drag == null ? 7 : 5, - width: _drag == null ? 90 : 94, - height: _drag == null ? 56 : 60, - child: IgnorePointer( - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(36), - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - const Color(0xFF24323F).withValues(alpha: 0.64), - const Color(0xFF101E2B).withValues(alpha: 0.8), - const Color(0xFF475866).withValues(alpha: 0.5), - ], - ), - border: Border.all( - color: Colors.white.withValues(alpha: 0.3), - width: 0.9, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.18), - blurRadius: 7, - offset: const Offset(0, 3), - ), - BoxShadow( - color: Colors.white.withValues(alpha: 0.12), - blurRadius: 3, - spreadRadius: 0.5, - ), - ], - ), - ), - ), - ), - Positioned( - left: 8, - right: 8, - top: 7, - bottom: 7, - child: Row( - children: [ - _item(0, Icons.calendar_month_rounded, '课表'), - _item(1, Icons.person_rounded, '我的'), - ], - ), - ), - Positioned( - left: 16, - right: 16, - bottom: 1, - child: Container( - height: 1.3, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Colors.white.withValues(alpha: 0.05), - Colors.white.withValues(alpha: 0.5), - Colors.white.withValues(alpha: 0.05), - ], - ), - ), - ), - ), - ], + child: child, + ), + ), + ), + ); + + @override + Widget build(BuildContext context) => SizedBox( + width: _width, + height: 70, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(40), + boxShadow: [ + BoxShadow( + color: _blue.withValues(alpha: 0.035), + blurRadius: 12, + offset: const Offset(0, 5), ), + ], + ), + child: _glass( + child: Stack( + children: [ + AnimatedPositioned( + duration: Duration(milliseconds: _drag == null ? 260 : 0), + curve: Curves.easeOutCubic, + left: 7 + _position * _travel, + top: _drag == null ? 7 : 5, + width: _drag == null ? 90 : 94, + height: _drag == null ? 56 : 60, + child: IgnorePointer( + child: _glass(lens: true, child: const SizedBox.expand()), + ), + ), + Positioned( + left: 8, + right: 8, + top: 7, + bottom: 7, + child: Row( + children: [ + _item(0, Icons.calendar_month_rounded, '课表'), + _item(1, Icons.person_rounded, '我的'), + ], + ), + ), + ], ), ), ), @@ -169,7 +117,6 @@ class _GlassDockState extends State { key: index == widget.selectedIndex ? const Key('dock-cursor') : null, behavior: HitTestBehavior.opaque, onTap: () => _select(index), - // Only the selected lens can start a drag. The pages have no swipe handlers. onHorizontalDragStart: index == widget.selectedIndex ? (_) => setState(() => _drag = widget.selectedIndex.toDouble()) : null, @@ -192,11 +139,96 @@ class _GlassDockState extends State { icon, size: 27, color: index == (_position >= 0.5 ? 1 : 0) - ? const Color(0xFF458BFF) - : Colors.white.withValues(alpha: 0.85), + ? _blue + : _blue.withValues(alpha: 0.48), ), ), ), ), ); } + +/// Thin paired rims and directional glints suggest rounded optical thickness. +/// All strong highlights stay on the edge; the centre remains clear. +class _GlassRimPainter extends CustomPainter { + const _GlassRimPainter({required this.lens}); + final bool lens; + static const _blue = Color(0xFF007AFF); + @override + void paint(Canvas canvas, Size size) { + final bounds = Offset.zero & size; + final outer = RRect.fromRectAndRadius( + bounds.deflate(0.7), + Radius.circular(size.height / 2), + ); + final inner = RRect.fromRectAndRadius( + bounds.deflate(2.2), + Radius.circular(size.height / 2 - 1.5), + ); + canvas.drawRRect( + outer, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = lens ? 1.25 : 1.1 + ..shader = ui.Gradient.sweep( + bounds.center, + [ + Colors.white.withValues(alpha: 0.90), + _blue.withValues(alpha: lens ? 0.30 : 0.15), + Colors.white.withValues(alpha: 0.10), + Colors.white.withValues(alpha: 0.86), + _blue.withValues(alpha: 0.12), + Colors.white.withValues(alpha: 0.90), + ], + [0.0, 0.18, 0.38, 0.60, 0.80, 1.0], + TileMode.clamp, + -math.pi / 2, + math.pi * 1.5, + ), + ); + canvas.drawRRect( + inner, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 0.7 + ..shader = ui.Gradient.linear( + bounds.topLeft, + bounds.bottomRight, + [ + Colors.white.withValues(alpha: 0.30), + _blue.withValues(alpha: lens ? 0.13 : 0.07), + Colors.white.withValues(alpha: 0.40), + ], + [0.0, 0.5, 1.0], + ), + ); + final shine = Path() + ..moveTo(size.height * 0.32, 4) + ..quadraticBezierTo( + size.width * 0.5, + 1.8, + size.width - size.height * 0.35, + 4, + ); + canvas.drawPath( + shine, + Paint() + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeWidth = 0.8 + ..shader = ui.Gradient.linear( + Offset.zero, + Offset(size.width, 0), + [ + Colors.white.withValues(alpha: 0), + Colors.white.withValues(alpha: 0.65), + Colors.white.withValues(alpha: 0), + ], + [0.0, 0.5, 1.0], + ), + ); + } + + @override + bool shouldRepaint(_GlassRimPainter oldDelegate) => oldDelegate.lens != lens; +} diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 5afe05c..caf0259 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -1,7 +1,7 @@ name: hepan_everyday description: Riverside Schedule 研究生课表 publish_to: none -version: 0.2.0+2 +version: 0.2.1+3 environment: sdk: ^3.13.5 dependencies: diff --git a/mobile/test/widgets/complete_metadata_test.dart b/mobile/test/widgets/complete_metadata_test.dart index b577e74..f670768 100644 --- a/mobile/test/widgets/complete_metadata_test.dart +++ b/mobile/test/widgets/complete_metadata_test.dart @@ -8,9 +8,17 @@ import 'package:hepan_everyday/domain/app_settings.dart'; void main() { setUpAll(tzdata.initializeTimeZones); - for (final scale in [1.0, 2.0]) { + for (final variant in [ + (1.0, 0.8), + (1.0, 1.25), + (1.0, 2.0), + (2.0, 0.8), + (2.0, 1.25), + (2.0, 2.0), + ]) { + final (scale, heightScale) = variant; testWidgets( - 'seven days and full teacher/room fit equal duration cards at scale $scale', + 'seven days and full teacher/room fit equal duration cards at scale $scale / length $heightScale', (tester) async { tester.view.physicalSize = const Size(360, 800); tester.view.devicePixelRatio = 1; @@ -45,7 +53,7 @@ void main() { events: events, monday: term.firstMonday, today: term.startDate, - settings: const AppSettings(), + settings: AppSettings(courseHeightScale: heightScale), ), ), ), diff --git a/mobile/test/widgets/course_length_test.dart b/mobile/test/widgets/course_length_test.dart new file mode 100644 index 0000000..27b4cb7 --- /dev/null +++ b/mobile/test/widgets/course_length_test.dart @@ -0,0 +1,149 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:timezone/data/latest.dart' as tzdata; +import 'package:hepan_everyday/app/app_controller.dart'; +import 'package:hepan_everyday/data/file_snapshot_store.dart'; +import 'package:hepan_everyday/domain/app_snapshot.dart'; +import 'package:hepan_everyday/features/profile/settings_page.dart'; +import 'package:hepan_everyday/features/timetable/week_grid.dart'; +import 'package:hepan_everyday/domain/schedule_engine.dart'; + +import '../support/fakes.dart'; +import '../domain/schedule_engine_test.dart' show course; + +void main() { + setUpAll(tzdata.initializeTimeZones); + test('old saved preferences gain longer default and malformed length is rejected', () { + final old = AppSnapshot().toJson(); + (old['settings'] as Map).remove('courseHeightScale'); + expect( + (AppSnapshot.fromJson(old).toJson()['settings'] + as Map)['courseHeightScale'], + 1.25, + ); + for (final value in [null, 'long', 0.5, 2.5, double.nan, double.infinity]) { + final json = AppSnapshot().toJson(); + (json['settings'] as Map)['courseHeightScale'] = value; + expect(() => AppSnapshot.fromJson(json), throwsFormatException); + } + }); + test('custom course length survives an actual disk reload', () async { + final directory = await Directory.systemTemp.createTemp( + 'riverside-length-', + ); + try { + final json = AppSnapshot().toJson(); + (json['settings'] as Map)['courseHeightScale'] = 1.8; + await FileSnapshotStore(directory).save(AppSnapshot.fromJson(json)); + final restored = await FileSnapshotStore(directory).load(); + expect((restored!.toJson()['settings'] as Map)['courseHeightScale'], 1.8); + } finally { + await directory.delete(recursive: true); + } + }); + testWidgets( + 'length slider persists across reopening and increases equal-duration bars', + (tester) async { + final store = MemoryStore(); + final term = Semester(startDate: DateTime(2026, 9, 7), totalWeeks: 1); + final json = AppSnapshot( + courses: [ + course(id: 'A', day: 1, first: 3, last: 4), + course(id: 'B', day: 7, first: 3, last: 4), + ], + ).toJson(); + (json['settings'] as Map)['semester'] = { + 'startDate': '2026-09-07', + 'totalWeeks': 1, + }; + await store.save(AppSnapshot.fromJson(json)); + final c = AppController( + store, + FakeNotifications(), + FakeCalendar(), + () => DateTime.utc(2026, 9, 7), + ); + await c.initialize(); + Future barHeight(AppController controller) async { + final events = ScheduleEngine.occurrences( + controller.snapshot.courses, + term, + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: WeekGrid( + events: events, + monday: term.firstMonday, + today: term.startDate, + settings: controller.snapshot.settings, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final a = tester.getRect(find.bySemanticsLabel('矩阵论,示例教师,示例教室').first), + b = tester.getRect(find.bySemanticsLabel('矩阵论,示例教师,示例教室').last); + expect(a.height, b.height); + expect(tester.getRect(find.text('周日')).right, lessThanOrEqualTo(800)); + return a.height; + } + + final before = await barHeight(c); + final alarmsBefore = + (c.notifications as FakeNotifications).reconciliations; + final calendarsBefore = (c.calendar as FakeCalendar).synchronizations; + await tester.pumpWidget(MaterialApp(home: SettingsPage(controller: c))); + await tester.pumpAndSettle(); + final slider = find.byKey(const Key('course-height-slider')); + await tester.ensureVisible(slider); + await tester.pumpAndSettle(); + final rect = tester.getRect(slider); + await tester.dragFrom( + Offset(rect.left + rect.width * 0.4, rect.center.dy), + Offset(rect.width * 0.4, 0), + ); + await tester.pumpAndSettle(); + final value = + (c.snapshot.toJson()['settings'] as Map)['courseHeightScale'] + as double; + expect(value, greaterThan(1.25)); + expect( + (c.notifications as FakeNotifications).reconciliations, + alarmsBefore, + ); + expect((c.calendar as FakeCalendar).synchronizations, calendarsBefore); + final restarted = AppController( + store, + FakeNotifications(), + FakeCalendar(), + () => DateTime.utc(2026, 9, 7), + ); + await restarted.initialize(); + expect( + (restarted.snapshot.toJson()['settings'] as Map)['courseHeightScale'], + value, + ); + expect(await barHeight(restarted), greaterThan(before)); + await tester.pumpWidget( + MaterialApp(home: SettingsPage(controller: restarted)), + ); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('恢复默认')); + await tester.tap(find.text('恢复默认')); + await tester.pumpAndSettle(); + expect( + (restarted.snapshot.toJson()['settings'] as Map)['courseHeightScale'], + 1.25, + ); + expect(await barHeight(restarted), before); + expect(tester.takeException(), isNull); + c.dispose(); + restarted.dispose(); + }, + ); +}