feat: deliver Riverside Schedule seven-day grid and school HTML import
This commit is contained in:
@@ -26,7 +26,7 @@ class HepanApp extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: '河畔Everyday',
|
||||
title: 'Riverside Schedule',
|
||||
theme: hepanTheme(),
|
||||
locale: const Locale('zh', 'CN'),
|
||||
supportedLocales: const [Locale('zh', 'CN')],
|
||||
|
||||
@@ -8,11 +8,13 @@ class ImportDraft {
|
||||
required this.explicitlyEmpty,
|
||||
required this.source,
|
||||
required this.parserVersion,
|
||||
this.semesterLabel,
|
||||
});
|
||||
final List<Course> courses;
|
||||
final bool explicitlyEmpty;
|
||||
final CourseSource source;
|
||||
final String parserVersion;
|
||||
final String? semesterLabel;
|
||||
}
|
||||
|
||||
class JsonScheduleImport {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'json_schedule_import.dart';
|
||||
import '../domain/course.dart';
|
||||
import '../features/school/school_page_adapter.dart';
|
||||
|
||||
class ScheduleFileImport {
|
||||
static ImportDraft parse(String content) {
|
||||
final trimmed = content.replaceFirst('\uFEFF', '').trimLeft();
|
||||
if (trimmed.startsWith('<')) {
|
||||
return SchoolPageAdapter().extract(
|
||||
trimmed,
|
||||
Uri.parse('https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1'),
|
||||
source: CourseSource.file,
|
||||
);
|
||||
}
|
||||
return JsonScheduleImport.parse(trimmed);
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ class _ImportPreviewState extends State<ImportPreview> {
|
||||
'${draft.courses.length} 条课程安排 · ${draft.source == CourseSource.demo ? '示例体验' : '导入预览'}',
|
||||
style: const TextStyle(color: river),
|
||||
),
|
||||
if (draft.semesterLabel != null) Text('学期:${draft.semesterLabel}'),
|
||||
const SizedBox(height: 18),
|
||||
if (empty)
|
||||
Text(draft.explicitlyEmpty ? '此操作会清空当前课表。' : '没有识别到课程,当前课表保持不变。'),
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../app/app_controller.dart';
|
||||
import '../../app/app_theme.dart';
|
||||
import '../../data/json_schedule_import.dart';
|
||||
import '../../data/schedule_file_import.dart';
|
||||
import 'settings_page.dart';
|
||||
import 'import_preview.dart';
|
||||
|
||||
@@ -26,7 +26,7 @@ class ProfilePage extends StatelessWidget {
|
||||
try {
|
||||
final file = await FilePicker.pickFile(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['json'],
|
||||
allowedExtensions: ['json', 'html', 'htm'],
|
||||
);
|
||||
if (file == null) return;
|
||||
final bytes = BytesBuilder(copy: false);
|
||||
@@ -38,7 +38,7 @@ class ProfilePage extends StatelessWidget {
|
||||
}
|
||||
bytes.add(chunk);
|
||||
}
|
||||
final draft = JsonScheduleImport.parse(utf8.decode(bytes.takeBytes()));
|
||||
final draft = ScheduleFileImport.parse(utf8.decode(bytes.takeBytes()));
|
||||
if (!context.mounted) return;
|
||||
await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
@@ -61,31 +61,16 @@ class ProfilePage extends StatelessWidget {
|
||||
return ListView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
22,
|
||||
26,
|
||||
10,
|
||||
22,
|
||||
145 + MediaQuery.paddingOf(context).bottom,
|
||||
),
|
||||
children: [
|
||||
Text(
|
||||
'YOUR LITTLE RIVERSIDE',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
letterSpacing: 1.8,
|
||||
color: river.withValues(alpha: 0.75),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
'我的河畔',
|
||||
style: TextStyle(fontSize: 29, fontWeight: FontWeight.w700),
|
||||
'Riverside Schedule',
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
'把时间,留给真正重要的事。',
|
||||
style: TextStyle(fontSize: 13, color: ink.withValues(alpha: 0.5)),
|
||||
),
|
||||
const SizedBox(height: 26),
|
||||
const SizedBox(height: 16),
|
||||
SurfaceCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -207,14 +192,14 @@ class ProfilePage extends StatelessWidget {
|
||||
_tile(
|
||||
Icons.file_download_outlined,
|
||||
'从文件导入',
|
||||
'先预览,再替换课表',
|
||||
'学校 HTML / JSON · 先预览再导入',
|
||||
() => _import(context),
|
||||
),
|
||||
Divider(height: 1, color: ink.withValues(alpha: 0.06)),
|
||||
_tile(
|
||||
Icons.auto_awesome_outlined,
|
||||
'体验示例课表',
|
||||
'无需登录,看看河畔的样子',
|
||||
'无需登录,预览课表样式',
|
||||
controller.showDemo,
|
||||
),
|
||||
],
|
||||
@@ -225,7 +210,7 @@ class ProfilePage extends StatelessWidget {
|
||||
child: Column(
|
||||
children: [
|
||||
const Text(
|
||||
'河畔Everyday',
|
||||
'Riverside Schedule',
|
||||
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
|
||||
@@ -78,7 +78,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('关闭日历同步'),
|
||||
content: const Text('是否删除河畔创建的课程日程?其他日程不会受影响。'),
|
||||
content: const Text('是否删除Riverside Schedule 创建的课程日程?其他日程不会受影响。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
|
||||
@@ -92,7 +92,7 @@ class _SchoolAuthPageState extends State<SchoolAuthPage> {
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: _showWeb ? '河畔登录界面' : '学校页面',
|
||||
tooltip: _showWeb ? '应用登录界面' : '学校页面',
|
||||
onPressed: () => setState(() => _showWeb = !_showWeb),
|
||||
icon: Icon(
|
||||
_showWeb ? Icons.person_outline : Icons.language_rounded,
|
||||
@@ -195,7 +195,7 @@ class _SchoolAuthPageState extends State<SchoolAuthPage> {
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'密码仅交给学校处理,不在河畔保存。需要验证码或其他验证时,将由学校页面继续处理。',
|
||||
'密码仅交给学校处理,不在应用中保存。需要验证码或其他验证时,将由学校页面继续处理。',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
|
||||
@@ -12,7 +12,10 @@ class SchoolNavigationPolicy {
|
||||
static bool isTimetable(Uri uri) =>
|
||||
_https(uri) &&
|
||||
uri.host == 'yjsjy.uestc.edu.cn' &&
|
||||
uri.path == '/pyxx/pygl/xskbcx/index/1';
|
||||
RegExp(
|
||||
r'^/pyxx/pygl/xskbcx/index/[1-9]\d*'
|
||||
r'$',
|
||||
).hasMatch(uri.path);
|
||||
static bool isSchoolPage(Uri uri) =>
|
||||
_https(uri) &&
|
||||
const {'idas.uestc.edu.cn', 'yjsjy.uestc.edu.cn'}.contains(uri.host);
|
||||
|
||||
@@ -1,16 +1,177 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:html/dom.dart' as dom;
|
||||
import 'package:html/parser.dart' as html;
|
||||
|
||||
import '../../data/json_schedule_import.dart';
|
||||
import '../../domain/course.dart';
|
||||
import 'school_navigation_policy.dart';
|
||||
|
||||
class UnsupportedSchoolLayout implements Exception {
|
||||
@override
|
||||
String toString() => '学校课表识别待联调,当前课表保持不变。';
|
||||
String toString() => '未识别到完整的学校课表,当前课表保持不变。请在课表查询页面保存 HTML 后重试。';
|
||||
}
|
||||
|
||||
/// Reads the supplied graduate-system table as data; never executes its scripts.
|
||||
class SchoolPageAdapter {
|
||||
ImportDraft extract(String html, Uri source) {
|
||||
// A real authenticated timetable fixture is required before registering a parser.
|
||||
// Unknown HTML must never erase the student's last valid imported timetable.
|
||||
throw UnsupportedSchoolLayout();
|
||||
ImportDraft extract(
|
||||
String content,
|
||||
Uri origin, {
|
||||
CourseSource source = CourseSource.school,
|
||||
}) {
|
||||
try {
|
||||
if (!SchoolNavigationPolicy.isTimetable(origin) ||
|
||||
source == CourseSource.demo ||
|
||||
utf8.encode(content).length > 2 * 1024 * 1024) {
|
||||
throw UnsupportedSchoolLayout();
|
||||
}
|
||||
final document = html.parse(content);
|
||||
final table = document.querySelector('#tbl');
|
||||
final selected = document.querySelector('#drpxq option[selected]');
|
||||
final term = selected?.attributes['value'];
|
||||
if (table == null ||
|
||||
term == null ||
|
||||
!RegExp(r'^\d+$').hasMatch(term) ||
|
||||
selected!.text.trim().isEmpty) {
|
||||
throw UnsupportedSchoolLayout();
|
||||
}
|
||||
final rows = table.querySelectorAll('tr');
|
||||
const weekdays = ['节次', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六', '星期日'];
|
||||
if (rows.length != 6 || rows.first.children.length != 8) {
|
||||
throw UnsupportedSchoolLayout();
|
||||
}
|
||||
for (var i = 0; i < 8; i++) {
|
||||
if (rows.first.children[i].text.trim() != weekdays[i]) {
|
||||
throw UnsupportedSchoolLayout();
|
||||
}
|
||||
}
|
||||
final courses = <String, Course>{};
|
||||
final ids = <String>{};
|
||||
const starts = [1, 3, 5, 7, 9];
|
||||
for (var row = 0; row < starts.length; row++) {
|
||||
final first = starts[row], last = first == 9 ? 11 : first + 1;
|
||||
final cells = rows[row + 1].children;
|
||||
if (cells.length != 8 ||
|
||||
cells.first.id != 't0$first' ||
|
||||
cells.first.text.trim() != '$first-$last节') {
|
||||
throw UnsupportedSchoolLayout();
|
||||
}
|
||||
for (var day = 1; day <= 7; day++) {
|
||||
final cell = cells[day];
|
||||
if (cell.id != 't$day$first') throw UnsupportedSchoolLayout();
|
||||
for (final line in _lines(cell)) {
|
||||
final record = RegExp(
|
||||
r'^(\d+)/(.+?)/(\d+(?:\.\d+)?)/(\d+(?:\.\d+)?)/([^/]+)/([^/]+)周/(\d+)[~~-](\d+)节/(.+)$',
|
||||
).firstMatch(line);
|
||||
if (record == null ||
|
||||
int.parse(record[7]!) != first ||
|
||||
int.parse(record[8]!) != last) {
|
||||
throw UnsupportedSchoolLayout();
|
||||
}
|
||||
final code = record[1]!,
|
||||
name = record[2]!.trim(),
|
||||
teacher = record[5]!.trim(),
|
||||
room = record[9]!.trim();
|
||||
final weeks = _weeks(record[6]!);
|
||||
final identity = jsonEncode([
|
||||
term,
|
||||
code,
|
||||
name,
|
||||
teacher,
|
||||
room,
|
||||
day,
|
||||
first,
|
||||
last,
|
||||
]);
|
||||
final previous = courses[identity];
|
||||
final id =
|
||||
previous?.id ??
|
||||
'uestc-$term-$code-$day-$first-$last-${_hash(identity)}';
|
||||
if (previous == null && !ids.add(id)) {
|
||||
throw UnsupportedSchoolLayout();
|
||||
}
|
||||
courses[identity] = Course(
|
||||
id: id,
|
||||
name: name,
|
||||
teacher: teacher,
|
||||
room: room,
|
||||
weekday: day,
|
||||
startPeriod: first,
|
||||
endPeriod: last,
|
||||
weeks: {...?previous?.weeks, ...weeks},
|
||||
source: source,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// An empty DOM can be an expired session or an incomplete response.
|
||||
if (courses.isEmpty) throw UnsupportedSchoolLayout();
|
||||
return ImportDraft(
|
||||
courses: List.unmodifiable(courses.values),
|
||||
explicitlyEmpty: false,
|
||||
source: source,
|
||||
parserVersion: 'uestc-graduate-html-v1',
|
||||
semesterLabel: selected.text.trim(),
|
||||
);
|
||||
} on UnsupportedSchoolLayout {
|
||||
rethrow;
|
||||
} catch (_) {
|
||||
throw UnsupportedSchoolLayout();
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _lines(dom.Element cell) {
|
||||
final buffer = StringBuffer();
|
||||
void visit(dom.Node node) {
|
||||
if (node is dom.Text) {
|
||||
buffer.write(node.text);
|
||||
} else if (node is dom.Element) {
|
||||
if (node.localName == 'br') {
|
||||
buffer.write('\n');
|
||||
} else {
|
||||
if (node.localName == 'script' || node.localName == 'style') {
|
||||
throw UnsupportedSchoolLayout();
|
||||
}
|
||||
for (final child in node.nodes) {
|
||||
visit(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (final node in cell.nodes) {
|
||||
visit(node);
|
||||
}
|
||||
return buffer
|
||||
.toString()
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Set<int> _weeks(String text) {
|
||||
final result = <int>{};
|
||||
for (final part in text.split(RegExp('[,,、]'))) {
|
||||
final range = RegExp(r'^\s*(\d+)(?:\s*[-~~]\s*(\d+))?\s*$')
|
||||
.firstMatch(part);
|
||||
if (range == null) throw UnsupportedSchoolLayout();
|
||||
final first = int.parse(range[1]!),
|
||||
last = int.parse(range[2] ?? range[1]!);
|
||||
if (first < 1 || last > 30 || last < first) {
|
||||
throw UnsupportedSchoolLayout();
|
||||
}
|
||||
result.addAll(List.generate(last - first + 1, (i) => first + i));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
String _hash(String value) {
|
||||
var hash = 2166136261;
|
||||
for (final byte in utf8.encode(value)) {
|
||||
hash = ((hash ^ byte) * 16777619) & 0xffffffff;
|
||||
}
|
||||
return hash.toRadixString(16).padLeft(8, '0');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,17 +179,36 @@ class SchoolReader {
|
||||
SchoolReader(this.adapter);
|
||||
final SchoolPageAdapter adapter;
|
||||
Future<ImportDraft>? _pending;
|
||||
int _generation = 0;
|
||||
Uri? _origin;
|
||||
bool get busy => _pending != null;
|
||||
void invalidate() {
|
||||
_generation++;
|
||||
_pending = null;
|
||||
_origin = null;
|
||||
}
|
||||
|
||||
Future<ImportDraft> read(Uri origin, Future<String> Function() getHtml) {
|
||||
if (!SchoolNavigationPolicy.isTimetable(origin) ||
|
||||
(_pending != null && origin != _origin)) {
|
||||
return Future.error(UnsupportedSchoolLayout());
|
||||
}
|
||||
if (_pending != null) return _pending!;
|
||||
final generation = _generation;
|
||||
_origin = origin;
|
||||
_pending = Future<ImportDraft>(() async {
|
||||
try {
|
||||
if (!SchoolNavigationPolicy.isTimetable(origin)) {
|
||||
throw UnsupportedSchoolLayout();
|
||||
}
|
||||
return adapter.extract(await getHtml(), origin);
|
||||
final content = await getHtml();
|
||||
if (generation != _generation) throw UnsupportedSchoolLayout();
|
||||
return adapter.extract(content, origin);
|
||||
} finally {
|
||||
_pending = null;
|
||||
if (generation == _generation) {
|
||||
_pending = null;
|
||||
_origin = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
return _pending!;
|
||||
|
||||
@@ -35,6 +35,9 @@ class SchoolSession extends ChangeNotifier {
|
||||
return NavigationDecision.navigate;
|
||||
},
|
||||
onPageStarted: (_) {
|
||||
_pageGeneration++;
|
||||
_reader.invalidate();
|
||||
pendingDraft = null;
|
||||
if (!hasOpened) {
|
||||
return;
|
||||
}
|
||||
@@ -72,6 +75,7 @@ class SchoolSession extends ChangeNotifier {
|
||||
hasOpened = false,
|
||||
formReady = false;
|
||||
bool get busy => _loading || _submitting || _reader.busy;
|
||||
int _pageGeneration = 0;
|
||||
ImportDraft? pendingDraft;
|
||||
String message = '请通过学校统一身份认证登录。';
|
||||
String get statusLabel => switch (connectionState) {
|
||||
@@ -79,7 +83,7 @@ class SchoolSession extends ChangeNotifier {
|
||||
SchoolConnectionState.authenticating => '请在学校页面完成认证',
|
||||
SchoolConnectionState.connected => '学校认证已确认',
|
||||
SchoolConnectionState.expired => '学校登录已过期',
|
||||
SchoolConnectionState.unknown => '学校页面已打开 · 课表识别待联调',
|
||||
SchoolConnectionState.unknown => '学校页面已打开 · 等待完整课表',
|
||||
};
|
||||
static final authUrl = Uri.parse(
|
||||
'https://idas.uestc.edu.cn/authserver/login',
|
||||
@@ -134,26 +138,33 @@ class SchoolSession extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
_loading = false;
|
||||
final generation = _pageGeneration;
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri != null && SchoolNavigationPolicy.canInjectCredentials(uri)) {
|
||||
connectionState = connectionState == SchoolConnectionState.connected
|
||||
? SchoolConnectionState.expired
|
||||
: SchoolConnectionState.authenticating;
|
||||
try {
|
||||
formReady = _truth(
|
||||
final ready = _truth(
|
||||
await web.runJavaScriptReturningResult(_readyScript),
|
||||
);
|
||||
if (!hasOpened || generation != _pageGeneration) return;
|
||||
formReady = ready;
|
||||
} catch (_) {
|
||||
if (!hasOpened || generation != _pageGeneration) return;
|
||||
formReady = false;
|
||||
}
|
||||
message = formReady ? '可以使用河畔界面登录;验证码会在学校页面完成。' : '请使用学校原页面完成认证。';
|
||||
message = formReady ? '可以使用应用界面登录;验证码会在学校页面完成。' : '请使用学校原页面完成认证。';
|
||||
} else if (uri != null && SchoolNavigationPolicy.isTimetable(uri)) {
|
||||
connectionState = SchoolConnectionState.unknown;
|
||||
message = '已打开课表页面,正在核对课程结构。';
|
||||
try {
|
||||
pendingDraft = await readTimetable();
|
||||
final draft = await readTimetable();
|
||||
if (!hasOpened || generation != _pageGeneration) return;
|
||||
pendingDraft = draft;
|
||||
connectionState = SchoolConnectionState.connected;
|
||||
} catch (error) {
|
||||
if (!hasOpened || generation != _pageGeneration) return;
|
||||
message = error is UnsupportedSchoolLayout
|
||||
? error.toString()
|
||||
: '课表读取未完成,已有课表保持不变。';
|
||||
@@ -238,6 +249,8 @@ class SchoolSession extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
_pageGeneration++;
|
||||
_reader.invalidate();
|
||||
hasOpened = false;
|
||||
await WebViewCookieManager().clearCookies();
|
||||
await web.clearCache();
|
||||
|
||||
@@ -33,57 +33,23 @@ class _TimetablePageState extends State<TimetablePage> {
|
||||
final sunday = monday.add(const Duration(days: 6));
|
||||
return ListView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
18,
|
||||
20,
|
||||
18,
|
||||
8,
|
||||
10,
|
||||
8,
|
||||
140 + MediaQuery.paddingOf(context).bottom,
|
||||
),
|
||||
children: [
|
||||
Text(
|
||||
'HEPAN / EVERYDAY',
|
||||
const Text(
|
||||
'Riverside Schedule',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
letterSpacing: 2.2,
|
||||
color: river.withValues(alpha: 0.8),
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 9),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'每一天,\n从容一点。',
|
||||
style: TextStyle(
|
||||
fontSize: 27,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.3,
|
||||
letterSpacing: -0.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 5),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
'${today.month} 月 ${today.day} 日',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: river,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
const SizedBox(height: 10),
|
||||
SurfaceCard(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
@@ -126,7 +92,7 @@ class _TimetablePageState extends State<TimetablePage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const SizedBox(height: 4),
|
||||
if (c.isDemo)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
@@ -172,7 +138,7 @@ class _TimetablePageState extends State<TimetablePage> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'左右滑动查看整周 · 点击课程查看详情',
|
||||
'点击课程查看完整信息',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 11, color: ink.withValues(alpha: 0.35)),
|
||||
),
|
||||
@@ -191,7 +157,7 @@ class _TimetablePageState extends State<TimetablePage> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'在「我的」设置学期并导入课程,\n或先看看河畔的示例课表。',
|
||||
'在「我的」设置学期并导入课程,\n或先看看示例课表。',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
|
||||
@@ -18,213 +18,18 @@ class WeekGrid extends StatelessWidget {
|
||||
final List<CourseOccurrence> events;
|
||||
final DateTime monday, today;
|
||||
final AppSettings settings;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scale = MediaQuery.textScalerOf(context).scale(12) / 12;
|
||||
final rowHeight = 78.0 * scale.clamp(1, 2.5),
|
||||
gap = 20.0,
|
||||
header = 56.0 * scale.clamp(1, 2);
|
||||
double top(int period) =>
|
||||
(period - 1) * rowHeight +
|
||||
(period > 4 ? gap : 0) +
|
||||
(period > 8 ? gap : 0);
|
||||
final height = 11 * rowHeight + 2 * gap;
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final columnWidth = math.max(
|
||||
90.0 * scale.clamp(1, 1.4),
|
||||
(constraints.maxWidth - 52) / 7,
|
||||
);
|
||||
final gridWidth = columnWidth * 7;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 52,
|
||||
height: header + height,
|
||||
child: Stack(
|
||||
children: [
|
||||
for (final slot in lessonSlots)
|
||||
Positioned(
|
||||
top: header + top(slot.period),
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: rowHeight,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'${slot.period}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
minuteLabel(slot.startMinute),
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
color: ink.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
minuteLabel(slot.endMinute),
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
color: ink.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
key: const Key('week-horizontal'),
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: SizedBox(
|
||||
width: gridWidth,
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: header,
|
||||
child: Row(
|
||||
children: List.generate(7, (i) {
|
||||
final date = monday.add(Duration(days: i));
|
||||
final active =
|
||||
date.year == today.year &&
|
||||
date.month == today.month &&
|
||||
date.day == today.day;
|
||||
return SizedBox(
|
||||
width: columnWidth,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
const [
|
||||
'周一',
|
||||
'周二',
|
||||
'周三',
|
||||
'周四',
|
||||
'周五',
|
||||
'周六',
|
||||
'周日',
|
||||
][i],
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: active
|
||||
? river
|
||||
: ink.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 9,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: active
|
||||
? river
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
'${date.day}',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: active ? Colors.white : ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: height,
|
||||
child: Stack(
|
||||
children: [
|
||||
for (var i = 0; i < 7; i++)
|
||||
Positioned(
|
||||
left: i * columnWidth,
|
||||
top: 0,
|
||||
width: columnWidth,
|
||||
height: height,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: i < 5
|
||||
? Colors.white.withValues(alpha: 0.45)
|
||||
: river.withValues(alpha: 0.025),
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: ink.withValues(alpha: 0.035),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
for (var p = 1; p <= 11; p++)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: top(p),
|
||||
child: Divider(
|
||||
height: 1,
|
||||
color: ink.withValues(alpha: 0.05),
|
||||
),
|
||||
),
|
||||
for (var day = 1; day <= 7; day++)
|
||||
..._cards(
|
||||
context,
|
||||
day,
|
||||
columnWidth,
|
||||
rowHeight,
|
||||
top,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
static const _nameStyle = TextStyle(
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.25,
|
||||
);
|
||||
static const _metadataStyle = TextStyle(fontSize: 9.5, height: 1.22);
|
||||
|
||||
List<Widget> _cards(
|
||||
BuildContext context,
|
||||
int day,
|
||||
double width,
|
||||
double rowHeight,
|
||||
double Function(int) top,
|
||||
) {
|
||||
final values = events.where((e) => e.course.weekday == day).toList();
|
||||
return [
|
||||
for (final e in values)
|
||||
_position(context, e, values, width, rowHeight, top),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _position(
|
||||
BuildContext context,
|
||||
CourseOccurrence e,
|
||||
List<CourseOccurrence> values,
|
||||
double width,
|
||||
double rowHeight,
|
||||
double Function(int) top,
|
||||
) {
|
||||
final connected = <CourseOccurrence>{e};
|
||||
List<CourseOccurrence> _group(CourseOccurrence event) {
|
||||
final values = events
|
||||
.where((e) => e.course.weekday == event.course.weekday)
|
||||
.toList();
|
||||
final connected = <CourseOccurrence>{event};
|
||||
var grew = true;
|
||||
while (grew) {
|
||||
grew = false;
|
||||
@@ -236,74 +41,330 @@ class WeekGrid extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
}
|
||||
final group = values.where(connected.contains).toList();
|
||||
final lane = group.indexOf(e), laneWidth = width / group.length;
|
||||
return Positioned(
|
||||
left: (e.course.weekday - 1) * width + lane * laneWidth + 3,
|
||||
top: top(e.course.startPeriod) + 4,
|
||||
width: math.max(12, laneWidth - 6),
|
||||
height:
|
||||
top(e.course.endPeriod) + rowHeight - top(e.course.startPeriod) - 8,
|
||||
child: Semantics(
|
||||
label: '${e.course.name},${e.course.teacher},${e.course.room}',
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
onTap: () => showCourseDetails(context, e),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: courseColor(e.course.id, settings),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.8)),
|
||||
return values.where(connected.contains).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final scaler = MediaQuery.textScalerOf(context);
|
||||
final scale = scaler.scale(10) / 10;
|
||||
const gutter = 34.0, gap = 16.0;
|
||||
final width = (constraints.maxWidth - gutter) / 7;
|
||||
final header = 56.0 * scale.clamp(1, 2.5);
|
||||
final groups = {for (final e in events) e: _group(e)};
|
||||
final defaultStyle = DefaultTextStyle.of(context).style;
|
||||
double measure(
|
||||
String value,
|
||||
TextStyle style,
|
||||
double available, {
|
||||
int? lines,
|
||||
}) {
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(text: value, style: defaultStyle.merge(style)),
|
||||
textDirection: Directionality.of(context),
|
||||
textScaler: scaler,
|
||||
maxLines: lines,
|
||||
ellipsis: lines == null ? null : '…',
|
||||
)..layout(maxWidth: available);
|
||||
final result = painter.height;
|
||||
painter.dispose();
|
||||
return result;
|
||||
}
|
||||
|
||||
// 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);
|
||||
for (final e in events) {
|
||||
final group = groups[e]!;
|
||||
if (group.first != e) continue;
|
||||
final available = math.max(1.0, width - 12);
|
||||
var entryHeight = 0.0;
|
||||
for (final value in group) {
|
||||
final required =
|
||||
measure(value.course.name, _nameStyle, available, lines: 2) +
|
||||
measure(value.course.teacher, _metadataStyle, available) +
|
||||
measure(value.course.room, _metadataStyle, available) +
|
||||
21;
|
||||
final periodHeight = group.length > 1
|
||||
? measure(
|
||||
'${value.course.startPeriod}–${value.course.endPeriod}节',
|
||||
_metadataStyle,
|
||||
available,
|
||||
) +
|
||||
3
|
||||
: 0.0;
|
||||
entryHeight = math.max(entryHeight, required + periodHeight);
|
||||
}
|
||||
final first = group.map((v) => v.course.startPeriod).reduce(math.min);
|
||||
final last = group.map((v) => v.course.endPeriod).reduce(math.max);
|
||||
final warning = group.length > 1
|
||||
? measure('冲突列表', _metadataStyle, width - 12) + 8
|
||||
: 0.0;
|
||||
rowHeight = math.max(
|
||||
rowHeight,
|
||||
(entryHeight * group.length + warning + 8) / (last - first + 1),
|
||||
);
|
||||
}
|
||||
rowHeight = rowHeight.ceilToDouble();
|
||||
double top(int period) =>
|
||||
(period - 1) * rowHeight +
|
||||
(period > 4 ? gap : 0) +
|
||||
(period > 8 ? gap : 0);
|
||||
final height = 11 * rowHeight + 2 * gap;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: gutter,
|
||||
height: header + height,
|
||||
child: Stack(
|
||||
children: [
|
||||
for (final slot in lessonSlots)
|
||||
Positioned(
|
||||
top: header + top(slot.period),
|
||||
left: 0,
|
||||
right: 3,
|
||||
height: rowHeight,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'${slot.period}',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
FittedBox(
|
||||
child: Text(
|
||||
minuteLabel(slot.startMinute),
|
||||
style: TextStyle(
|
||||
fontSize: 8,
|
||||
color: ink.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
),
|
||||
FittedBox(
|
||||
child: Text(
|
||||
minuteLabel(slot.endMinute),
|
||||
style: TextStyle(
|
||||
fontSize: 8,
|
||||
color: ink.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: header,
|
||||
child: Row(
|
||||
children: List.generate(7, (i) {
|
||||
final date = monday.add(Duration(days: i));
|
||||
final active =
|
||||
date.year == today.year &&
|
||||
date.month == today.month &&
|
||||
date.day == today.day;
|
||||
return SizedBox(
|
||||
width: width,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
const [
|
||||
'周一',
|
||||
'周二',
|
||||
'周三',
|
||||
'周四',
|
||||
'周五',
|
||||
'周六',
|
||||
'周日',
|
||||
][i],
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: active
|
||||
? river
|
||||
: ink.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 5,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? river : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
child: Text(
|
||||
'${date.day}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: active ? Colors.white : ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: height,
|
||||
child: Stack(
|
||||
children: [
|
||||
for (var i = 0; i < 7; i++)
|
||||
Positioned(
|
||||
left: i * width,
|
||||
top: 0,
|
||||
width: width,
|
||||
height: height,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: i < 5
|
||||
? Colors.white.withValues(alpha: 0.45)
|
||||
: river.withValues(alpha: 0.025),
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: ink.withValues(alpha: 0.04),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
for (var p = 1; p <= 11; p++)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: top(p),
|
||||
child: Divider(
|
||||
height: 1,
|
||||
color: ink.withValues(alpha: 0.055),
|
||||
),
|
||||
),
|
||||
for (final e in events)
|
||||
if (groups[e]!.first == e)
|
||||
_block(context, groups[e]!, width, rowHeight, top),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Widget _block(
|
||||
BuildContext context,
|
||||
List<CourseOccurrence> group,
|
||||
double width,
|
||||
double rowHeight,
|
||||
double Function(int) top,
|
||||
) {
|
||||
final first = group.map((e) => e.course.startPeriod).reduce(math.min);
|
||||
final last = group.map((e) => e.course.endPeriod).reduce(math.max);
|
||||
return Positioned(
|
||||
left: (group.first.course.weekday - 1) * width + 2,
|
||||
top: top(first) + 4,
|
||||
width: width - 4,
|
||||
height: (last - first + 1) * rowHeight - 8,
|
||||
child: group.length == 1
|
||||
? _entry(context, group.first)
|
||||
: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: river.withValues(alpha: 0.06),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: river.withValues(alpha: 0.15)),
|
||||
),
|
||||
// Conflict entries are explicitly a list, with their own lesson ranges.
|
||||
// They are not separate time-scaled bars squeezed into narrow lanes.
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (group.length > 1)
|
||||
const Text(
|
||||
'时间冲突',
|
||||
style: TextStyle(fontSize: 9, color: Color(0xFFB45D54)),
|
||||
),
|
||||
Text(
|
||||
e.course.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.25,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
e.course.teacher,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: ink.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
e.course.room,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: ink.withValues(alpha: 0.6),
|
||||
height: 1.2,
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 3, vertical: 3),
|
||||
child: Text(
|
||||
'冲突列表',
|
||||
style: TextStyle(
|
||||
fontSize: 9.5,
|
||||
height: 1.22,
|
||||
color: Color(0xFFB45D54),
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final e in group)
|
||||
Expanded(child: _entry(context, e, inConflict: true)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _entry(
|
||||
BuildContext context,
|
||||
CourseOccurrence e, {
|
||||
bool inConflict = false,
|
||||
}) => Semantics(
|
||||
label: '${e.course.name},${e.course.teacher},${e.course.room}',
|
||||
button: true,
|
||||
excludeSemantics: true,
|
||||
onTap: () => showCourseDetails(context, e),
|
||||
child: GestureDetector(
|
||||
onTap: () => showCourseDetails(context, e),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 6),
|
||||
decoration: inConflict
|
||||
? null
|
||||
: BoxDecoration(
|
||||
color: courseColor(e.course.id, settings),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.8)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
e.course.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: _nameStyle,
|
||||
),
|
||||
if (inConflict) ...[
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'${e.course.startPeriod}–${e.course.endPeriod}节',
|
||||
style: _metadataStyle,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
e.course.teacher,
|
||||
style: _metadataStyle.copyWith(
|
||||
color: ink.withValues(alpha: 0.85),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
e.course.room,
|
||||
style: _metadataStyle.copyWith(color: ink.withValues(alpha: 0.7)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ class AndroidCalendarService extends CalendarService {
|
||||
calendarId: calendarId,
|
||||
title: occurrence.course.name,
|
||||
room: occurrence.course.room,
|
||||
description: '任课老师:${occurrence.course.teacher}\n河畔Everyday',
|
||||
description: '任课老师:${occurrence.course.teacher}\nRiverside Schedule',
|
||||
startMs: occurrence.start.millisecondsSinceEpoch,
|
||||
endMs: occurrence.end.millisecondsSinceEpoch,
|
||||
),
|
||||
|
||||
@@ -2,9 +2,7 @@ import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app/app_theme.dart';
|
||||
|
||||
class GlassDock extends StatelessWidget {
|
||||
class GlassDock extends StatefulWidget {
|
||||
const GlassDock({
|
||||
super.key,
|
||||
required this.selectedIndex,
|
||||
@@ -12,74 +10,191 @@ class GlassDock extends StatelessWidget {
|
||||
});
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onSelected;
|
||||
@override
|
||||
State<GlassDock> createState() => _GlassDockState();
|
||||
}
|
||||
|
||||
class _GlassDockState extends State<GlassDock> {
|
||||
static const _width = 192.0, _travel = 88.0;
|
||||
double? _drag;
|
||||
double get _position => _drag ?? widget.selectedIndex.toDouble();
|
||||
void _select(int index) {
|
||||
setState(() => _drag = null);
|
||||
widget.onSelected(index);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
width: _width,
|
||||
height: 70,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: ink.withValues(alpha: 0.13),
|
||||
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: 22, sigmaY: 22),
|
||||
child: Container(
|
||||
width: 166,
|
||||
height: 68,
|
||||
padding: const EdgeInsets.all(7),
|
||||
filter: ImageFilter.blur(sigmaX: 24, sigmaY: 24),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Colors.white.withValues(alpha: 0.85),
|
||||
Colors.white.withValues(alpha: 0.5),
|
||||
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.95),
|
||||
color: Colors.white.withValues(alpha: 0.55),
|
||||
width: 1.2,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
child: Stack(
|
||||
children: [
|
||||
_item(0, Icons.calendar_month_rounded, '课表'),
|
||||
_item(1, Icons.person_outline_rounded, '我的'),
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
Widget _item(int index, IconData icon, String label) => Semantics(
|
||||
label: label,
|
||||
button: true,
|
||||
selected: index == selectedIndex,
|
||||
excludeSemantics: true,
|
||||
child: GestureDetector(
|
||||
onTap: () => onSelected(index),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
width: 66,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
color: index == selectedIndex
|
||||
? river.withValues(alpha: 0.12)
|
||||
: Colors.transparent,
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 27,
|
||||
color: index == selectedIndex ? river : ink.withValues(alpha: 0.45),
|
||||
Widget _item(int index, IconData icon, String label) => Expanded(
|
||||
child: Semantics(
|
||||
label: label,
|
||||
button: true,
|
||||
selected: index == widget.selectedIndex,
|
||||
excludeSemantics: true,
|
||||
onTap: () => _select(index),
|
||||
child: GestureDetector(
|
||||
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,
|
||||
onHorizontalDragUpdate: index == widget.selectedIndex
|
||||
? (details) => setState(
|
||||
() => _drag = (_position + details.delta.dx / _travel).clamp(
|
||||
0,
|
||||
1,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onHorizontalDragEnd: index == widget.selectedIndex
|
||||
? (_) => _select(_position >= 0.5 ? 1 : 0)
|
||||
: null,
|
||||
onHorizontalDragCancel: index == widget.selectedIndex
|
||||
? () => setState(() => _drag = null)
|
||||
: null,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 27,
|
||||
color: index == (_position >= 0.5 ? 1 : 0)
|
||||
? const Color(0xFF458BFF)
|
||||
: Colors.white.withValues(alpha: 0.85),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user