feat: add course bar length setting and clear blue glass Dock
This commit is contained in:
@@ -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)。
|
||||||
|
|||||||
@@ -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(
|
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, {
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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