feat: add course bar length setting and clear blue glass Dock

This commit is contained in:
Codex
2026-10-13 12:51:27 +08:00
parent 8403be05da
commit 9193fd8e43
11 changed files with 444 additions and 138 deletions
+4 -4
View File
@@ -1,10 +1,10 @@
# Riverside Schedule # 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/`。 仓库根目录运行 `./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)。
+7
View File
@@ -0,0 +1,7 @@
# 0.2.1 透明蓝色Dock和课程条长度
2026-10-13:Dock底座及选中游标改成低透明度白色/蓝色玻璃,移除黑灰色渐变和黑色投影;底座与游标使用极低填充透明度和轻微背景模糊;通过双层光学边缘、方向性高光与游标拖动形变表现玻璃厚度,避免磨砂填充。保留图标点击和游标拖动。
新增「我的 → 课表设置 → 课程条长度」:默认125%,可调80%–200%,松开滑块后保存,一键恢复默认。统一调整节次行高,相同节数课程条等长;老师和教室的完整显示优先于过短的设置。提醒时间和日历日程不因外观调节而重新生成。
新增测试覆盖旧数据默认值迁移、非法长度拒绝、实际文件保存后重读、滑块调节/控制器重开、条形实际变长且等长、七天同屏及不触发通知/日历重排。完整79项测试通过,静态检查无问题。安卓外观与设置保存验证截图位于dist/screenshots。
+15 -2
View File
@@ -88,18 +88,31 @@ class AppController extends ChangeNotifier {
Future<void> updateSettings( Future<void> updateSettings(
AppSettings Function(AppSettings current) change, AppSettings Function(AppSettings current) change,
) => _run(() async { ) => _run(() async {
final value = change(snapshot.settings); final previous = snapshot.settings;
final value = change(previous);
if (![5, 10, 15, 30, 60].contains(value.reminderMinutes)) { if (![5, 10, 15, 30, 60].contains(value.reminderMinutes)) {
throw ArgumentError('提醒时间无效'); throw ArgumentError('提醒时间无效');
} }
if (!value.courseHeightScale.isFinite ||
value.courseHeightScale < AppSettings.minCourseHeightScale ||
value.courseHeightScale > AppSettings.maxCourseHeightScale) {
throw ArgumentError('课程条长度无效');
}
await _save(snapshot.copyWith(settings: value)); await _save(snapshot.copyWith(settings: value));
if (isDemo) { if (isDemo) {
_demoSettings = _demoSettings!.copyWith( _demoSettings = _demoSettings!.copyWith(
colorMode: value.colorMode, colorMode: value.colorMode,
singleColorArgb: value.singleColorArgb, 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<void> applyImport( Future<void> applyImport(
ImportDraft draft, { ImportDraft draft, {
+7
View File
@@ -11,7 +11,12 @@ class AppSettings {
this.singleColorArgb = 0xFF348F91, this.singleColorArgb = 0xFF348F91,
this.calendarEnabled = false, this.calendarEnabled = false,
this.calendarId, 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 Semester? semester;
final bool remindersEnabled, calendarEnabled; final bool remindersEnabled, calendarEnabled;
final int reminderMinutes, singleColorArgb; final int reminderMinutes, singleColorArgb;
@@ -25,6 +30,7 @@ class AppSettings {
int? singleColorArgb, int? singleColorArgb,
bool? calendarEnabled, bool? calendarEnabled,
String? calendarId, String? calendarId,
double? courseHeightScale,
}) => AppSettings( }) => AppSettings(
semester: semester ?? this.semester, semester: semester ?? this.semester,
remindersEnabled: remindersEnabled ?? this.remindersEnabled, remindersEnabled: remindersEnabled ?? this.remindersEnabled,
@@ -33,5 +39,6 @@ class AppSettings {
singleColorArgb: singleColorArgb ?? this.singleColorArgb, singleColorArgb: singleColorArgb ?? this.singleColorArgb,
calendarEnabled: calendarEnabled ?? this.calendarEnabled, calendarEnabled: calendarEnabled ?? this.calendarEnabled,
calendarId: calendarId ?? this.calendarId, calendarId: calendarId ?? this.calendarId,
courseHeightScale: courseHeightScale ?? this.courseHeightScale,
); );
} }
+11
View File
@@ -62,6 +62,7 @@ class AppSnapshot {
'singleColorArgb': settings.singleColorArgb, 'singleColorArgb': settings.singleColorArgb,
'calendarEnabled': settings.calendarEnabled, 'calendarEnabled': settings.calendarEnabled,
'calendarId': settings.calendarId, 'calendarId': settings.calendarId,
'courseHeightScale': settings.courseHeightScale,
}, },
'importMetadata': importMetadata == null 'importMetadata': importMetadata == null
? null ? null
@@ -90,6 +91,15 @@ class AppSnapshot {
} }
final color = s['singleColorArgb'] as int; final color = s['singleColorArgb'] as int;
if (color < 0 || color > 0xFFFFFFFF) throw const FormatException('颜色无效'); 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 sem = s['semester'];
final term = sem == null final term = sem == null
? null ? null
@@ -110,6 +120,7 @@ class AppSnapshot {
singleColorArgb: color, singleColorArgb: color,
calendarEnabled: s['calendarEnabled'] as bool, calendarEnabled: s['calendarEnabled'] as bool,
calendarId: s['calendarId'] as String?, calendarId: s['calendarId'] as String?,
courseHeightScale: rawHeight.toDouble(),
), ),
importMetadata: metadata == null importMetadata: metadata == null
? null ? null
@@ -17,6 +17,8 @@ class SettingsPage extends StatefulWidget {
class _SettingsPageState extends State<SettingsPage> { class _SettingsPageState extends State<SettingsPage> {
late TextEditingController _weeks; late TextEditingController _weeks;
DateTime? _start; DateTime? _start;
double? _courseHeightDraft;
bool _savingHeight = false;
String? _weeksError, _dateError; String? _weeksError, _dateError;
@override @override
void initState() { void initState() {
@@ -100,6 +102,21 @@ class _SettingsPageState extends State<SettingsPage> {
} }
} }
Future<void> _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) => void _notice(String text) =>
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
@override @override
@@ -203,6 +220,68 @@ class _SettingsPageState extends State<SettingsPage> {
), ),
), ),
const SizedBox(height: 22), 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('课前提醒'), _heading('课前提醒'),
SurfaceCard( SurfaceCard(
child: Column( child: Column(
+1 -1
View File
@@ -74,7 +74,7 @@ class WeekGrid extends StatelessWidget {
// A single shared lesson height keeps equal-duration cards identical in size. // A single shared lesson height keeps equal-duration cards identical in size.
// Increase that height for full metadata, never stretch one card independently. // 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) { for (final e in events) {
final group = groups[e]!; final group = groups[e]!;
if (group.first != e) continue; if (group.first != e) continue;
+159 -127
View File
@@ -1,4 +1,5 @@
import 'dart:ui'; import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -16,6 +17,7 @@ class GlassDock extends StatefulWidget {
class _GlassDockState extends State<GlassDock> { class _GlassDockState extends State<GlassDock> {
static const _width = 192.0, _travel = 88.0; static const _width = 192.0, _travel = 88.0;
static const _blue = Color(0xFF007AFF);
double? _drag; double? _drag;
double get _position => _drag ?? widget.selectedIndex.toDouble(); double get _position => _drag ?? widget.selectedIndex.toDouble();
void _select(int index) { void _select(int index) {
@@ -23,137 +25,83 @@ class _GlassDockState extends State<GlassDock> {
widget.onSelected(index); widget.onSelected(index);
} }
@override Widget _glass({required Widget child, bool lens = false}) => ClipRRect(
Widget build(BuildContext context) => Container( borderRadius: BorderRadius.circular(lens ? 36 : 40),
width: _width, child: BackdropFilter(
height: 70, // Keep the underlying timetable visible. Optical rims provide the glass
decoration: BoxDecoration( // thickness instead of a frosted, heavily tinted fill.
borderRadius: BorderRadius.circular(40), filter: ui.ImageFilter.blur(
boxShadow: [ sigmaX: lens ? 0.6 : 1.5,
BoxShadow( sigmaY: lens ? 0.6 : 1.5,
color: const Color(0xFF10212E).withValues(alpha: 0.22), ),
blurRadius: 28, child: CustomPaint(
offset: const Offset(0, 10), foregroundPainter: _GlassRimPainter(lens: lens),
),
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),
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(40),
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [ colors: lens
const Color(0xFFB9C0CA).withValues(alpha: 0.58), ? [
const Color(0xFF56616D).withValues(alpha: 0.34), Colors.white.withValues(alpha: 0.045),
const Color(0xFFA1ADB8).withValues(alpha: 0.42), _blue.withValues(alpha: 0.018),
], Colors.white.withValues(alpha: 0.012),
), ]
border: Border.all( : [
color: Colors.white.withValues(alpha: 0.55), Colors.white.withValues(alpha: 0.028),
width: 1.2, _blue.withValues(alpha: 0.005),
Colors.white.withValues(alpha: 0.012),
],
), ),
), ),
child: Stack( child: child,
children: [ ),
Positioned( ),
left: 18, ),
right: 18, );
top: 1,
child: Container( @override
height: 1.5, Widget build(BuildContext context) => SizedBox(
decoration: BoxDecoration( width: _width,
borderRadius: BorderRadius.circular(30), height: 70,
gradient: LinearGradient( child: DecoratedBox(
colors: [ decoration: BoxDecoration(
Colors.white.withValues(alpha: 0), borderRadius: BorderRadius.circular(40),
Colors.white.withValues(alpha: 0.8), boxShadow: [
Colors.white.withValues(alpha: 0.08), BoxShadow(
], color: _blue.withValues(alpha: 0.035),
), blurRadius: 12,
), offset: const Offset(0, 5),
),
),
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: _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<GlassDock> {
key: index == widget.selectedIndex ? const Key('dock-cursor') : null, key: index == widget.selectedIndex ? const Key('dock-cursor') : null,
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: () => _select(index), onTap: () => _select(index),
// Only the selected lens can start a drag. The pages have no swipe handlers.
onHorizontalDragStart: index == widget.selectedIndex onHorizontalDragStart: index == widget.selectedIndex
? (_) => setState(() => _drag = widget.selectedIndex.toDouble()) ? (_) => setState(() => _drag = widget.selectedIndex.toDouble())
: null, : null,
@@ -192,11 +139,96 @@ class _GlassDockState extends State<GlassDock> {
icon, icon,
size: 27, size: 27,
color: index == (_position >= 0.5 ? 1 : 0) color: index == (_position >= 0.5 ? 1 : 0)
? const Color(0xFF458BFF) ? _blue
: Colors.white.withValues(alpha: 0.85), : _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;
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: hepan_everyday name: hepan_everyday
description: Riverside Schedule 研究生课表 description: Riverside Schedule 研究生课表
publish_to: none publish_to: none
version: 0.2.0+2 version: 0.2.1+3
environment: environment:
sdk: ^3.13.5 sdk: ^3.13.5
dependencies: dependencies:
@@ -8,9 +8,17 @@ import 'package:hepan_everyday/domain/app_settings.dart';
void main() { void main() {
setUpAll(tzdata.initializeTimeZones); 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( 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) async {
tester.view.physicalSize = const Size(360, 800); tester.view.physicalSize = const Size(360, 800);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
@@ -45,7 +53,7 @@ void main() {
events: events, events: events,
monday: term.firstMonday, monday: term.firstMonday,
today: term.startDate, today: term.startDate,
settings: const AppSettings(), settings: AppSettings(courseHeightScale: heightScale),
), ),
), ),
), ),
+149
View File
@@ -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<double> 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();
},
);
}