feat: add course bar length setting and clear blue glass Dock
This commit is contained in:
@@ -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)。
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# 0.2.1 透明蓝色Dock和课程条长度
|
||||
|
||||
2026-10-13:Dock底座及选中游标改成低透明度白色/蓝色玻璃,移除黑灰色渐变和黑色投影;底座与游标使用极低填充透明度和轻微背景模糊;通过双层光学边缘、方向性高光与游标拖动形变表现玻璃厚度,避免磨砂填充。保留图标点击和游标拖动。
|
||||
|
||||
新增「我的 → 课表设置 → 课程条长度」:默认125%,可调80%–200%,松开滑块后保存,一键恢复默认。统一调整节次行高,相同节数课程条等长;老师和教室的完整显示优先于过短的设置。提醒时间和日历日程不因外观调节而重新生成。
|
||||
|
||||
新增测试覆盖旧数据默认值迁移、非法长度拒绝、实际文件保存后重读、滑块调节/控制器重开、条形实际变长且等长、七天同屏及不触发通知/日历重排。完整79项测试通过,静态检查无问题。安卓外观与设置保存验证截图位于dist/screenshots。
|
||||
@@ -88,18 +88,31 @@ class AppController extends ChangeNotifier {
|
||||
Future<void> 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<void> applyImport(
|
||||
ImportDraft draft, {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,6 +17,8 @@ class SettingsPage extends StatefulWidget {
|
||||
class _SettingsPageState extends State<SettingsPage> {
|
||||
late TextEditingController _weeks;
|
||||
DateTime? _start;
|
||||
double? _courseHeightDraft;
|
||||
bool _savingHeight = false;
|
||||
String? _weeksError, _dateError;
|
||||
@override
|
||||
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) =>
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
|
||||
@override
|
||||
@@ -203,6 +220,68 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
),
|
||||
),
|
||||
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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
+159
-127
@@ -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<GlassDock> {
|
||||
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<GlassDock> {
|
||||
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<GlassDock> {
|
||||
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<GlassDock> {
|
||||
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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user