feat: add two-tab timetable UI and glass dock
This commit is contained in:
@@ -2,7 +2,6 @@ import 'package:flutter/foundation.dart';
|
|||||||
|
|
||||||
import '../domain/app_snapshot.dart';
|
import '../domain/app_snapshot.dart';
|
||||||
import '../domain/app_settings.dart';
|
import '../domain/app_settings.dart';
|
||||||
import '../domain/course.dart';
|
|
||||||
import '../data/snapshot_store.dart';
|
import '../data/snapshot_store.dart';
|
||||||
import '../data/json_schedule_import.dart';
|
import '../data/json_schedule_import.dart';
|
||||||
import '../services/notification_service.dart';
|
import '../services/notification_service.dart';
|
||||||
@@ -29,6 +28,9 @@ class AppController extends ChangeNotifier {
|
|||||||
isDemo ? _demoSettings! : snapshot.settings;
|
isDemo ? _demoSettings! : snapshot.settings;
|
||||||
Future<void> _run(Future<void> Function() operation) {
|
Future<void> _run(Future<void> Function() operation) {
|
||||||
final next = _queue.then((_) async {
|
final next = _queue.then((_) async {
|
||||||
|
if (storageReady) {
|
||||||
|
lastError = null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await operation();
|
await operation();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -84,14 +86,16 @@ class AppController extends ChangeNotifier {
|
|||||||
isDemo = false;
|
isDemo = false;
|
||||||
});
|
});
|
||||||
Future<void> updateSettings(AppSettings value) => _run(() async {
|
Future<void> updateSettings(AppSettings value) => _run(() async {
|
||||||
if (![5, 10, 15, 30, 60].contains(value.reminderMinutes))
|
if (![5, 10, 15, 30, 60].contains(value.reminderMinutes)) {
|
||||||
throw ArgumentError('提醒时间无效');
|
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,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
await _reconcile();
|
await _reconcile();
|
||||||
});
|
});
|
||||||
Future<void> applyImport(
|
Future<void> applyImport(
|
||||||
@@ -101,8 +105,9 @@ class AppController extends ChangeNotifier {
|
|||||||
if (draft.courses.isEmpty && (!draft.explicitlyEmpty || !confirmedEmpty)) {
|
if (draft.courses.isEmpty && (!draft.explicitlyEmpty || !confirmedEmpty)) {
|
||||||
throw const FormatException('未检测到课程,已有课表保持不变');
|
throw const FormatException('未检测到课程,已有课表保持不变');
|
||||||
}
|
}
|
||||||
if (draft.courses.map((c) => c.id).toSet().length != draft.courses.length)
|
if (draft.courses.map((c) => c.id).toSet().length != draft.courses.length) {
|
||||||
throw const FormatException('课程标识重复');
|
throw const FormatException('课程标识重复');
|
||||||
|
}
|
||||||
if (draft.source == CourseSource.demo) {
|
if (draft.source == CourseSource.demo) {
|
||||||
_demo = draft.courses;
|
_demo = draft.courses;
|
||||||
isDemo = true;
|
isDemo = true;
|
||||||
@@ -117,8 +122,9 @@ class AppController extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (draft.courses.any((c) => c.source != draft.source))
|
if (draft.courses.any((c) => c.source != draft.source)) {
|
||||||
throw const FormatException('课程来源不一致');
|
throw const FormatException('课程来源不一致');
|
||||||
|
}
|
||||||
await _save(
|
await _save(
|
||||||
snapshot.copyWith(
|
snapshot.copyWith(
|
||||||
courses: draft.courses,
|
courses: draft.courses,
|
||||||
@@ -195,6 +201,9 @@ class AppController extends ChangeNotifier {
|
|||||||
});
|
});
|
||||||
Future<void> refreshServices() => _run(_reconcile);
|
Future<void> refreshServices() => _run(_reconcile);
|
||||||
Future<void> _reconcile() async {
|
Future<void> _reconcile() async {
|
||||||
|
if (!storageReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
final settings = snapshot.settings;
|
final settings = snapshot.settings;
|
||||||
final occurrences = settings.semester == null
|
final occurrences = settings.semester == null
|
||||||
? <CourseOccurrence>[]
|
? <CourseOccurrence>[]
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../domain/app_settings.dart';
|
||||||
|
|
||||||
|
const river = Color(0xFF348F91);
|
||||||
|
const ink = Color(0xFF183F43);
|
||||||
|
const canvas = Color(0xFFF3F7F6);
|
||||||
|
const coursePalette = [
|
||||||
|
Color(0xFFDDEEEF),
|
||||||
|
Color(0xFFE3E7F6),
|
||||||
|
Color(0xFFF6E7D6),
|
||||||
|
Color(0xFFE6EDDE),
|
||||||
|
Color(0xFFF3DFE7),
|
||||||
|
Color(0xFFE6E3F4),
|
||||||
|
];
|
||||||
|
Color courseColor(String id, AppSettings settings) =>
|
||||||
|
settings.colorMode == ColorMode.single
|
||||||
|
? Color(settings.singleColorArgb).withValues(alpha: 0.18)
|
||||||
|
: coursePalette[id.codeUnits.fold<int>(0, (a, b) => a + b) %
|
||||||
|
coursePalette.length];
|
||||||
|
ThemeData hepanTheme() => ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: ColorScheme.fromSeed(seedColor: river, surface: Colors.white),
|
||||||
|
scaffoldBackgroundColor: canvas,
|
||||||
|
textTheme: ThemeData.light().textTheme.apply(
|
||||||
|
bodyColor: ink,
|
||||||
|
displayColor: ink,
|
||||||
|
),
|
||||||
|
appBarTheme: const AppBarTheme(
|
||||||
|
backgroundColor: canvas,
|
||||||
|
foregroundColor: ink,
|
||||||
|
centerTitle: false,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
filled: true,
|
||||||
|
fillColor: canvas,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
filledButtonTheme: FilledButtonThemeData(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: river,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 15),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
class SurfaceCard extends StatelessWidget {
|
||||||
|
const SurfaceCard({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.padding = const EdgeInsets.all(20),
|
||||||
|
});
|
||||||
|
final Widget child;
|
||||||
|
final EdgeInsets padding;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Container(
|
||||||
|
padding: padding,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.92),
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
border: Border.all(color: Colors.white),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: ink.withValues(alpha: 0.035),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Material(type: MaterialType.transparency, child: child),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'app_controller.dart';
|
||||||
|
import 'app_theme.dart';
|
||||||
|
import '../widgets/glass_dock.dart';
|
||||||
|
import '../features/timetable/timetable_page.dart';
|
||||||
|
import '../features/profile/profile_page.dart';
|
||||||
|
|
||||||
|
class HepanApp extends StatelessWidget {
|
||||||
|
const HepanApp({super.key, required this.controller, this.schoolBuilder});
|
||||||
|
final AppController controller;
|
||||||
|
final Widget Function()? schoolBuilder;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => MaterialApp(
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
title: '河畔Everyday',
|
||||||
|
theme: hepanTheme(),
|
||||||
|
home: _Shell(controller: controller, schoolBuilder: schoolBuilder),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Shell extends StatefulWidget {
|
||||||
|
const _Shell({required this.controller, this.schoolBuilder});
|
||||||
|
final AppController controller;
|
||||||
|
final Widget Function()? schoolBuilder;
|
||||||
|
@override
|
||||||
|
State<_Shell> createState() => _ShellState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ShellState extends State<_Shell> with WidgetsBindingObserver {
|
||||||
|
int _tab = 0;
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
widget.controller.refreshServices();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _school() {
|
||||||
|
if (widget.schoolBuilder != null) {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute<void>(builder: (_) => widget.schoolBuilder!()),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('学校认证'),
|
||||||
|
content: const Text('学校认证入口正在接入。你可以先体验示例课表或导入课表文件。'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('知道了'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => AnimatedBuilder(
|
||||||
|
animation: widget.controller,
|
||||||
|
builder: (context, _) => Scaffold(
|
||||||
|
body: Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFFF0F7F5), Color(0xFFF7F8F5), Color(0xFFEFF5F3)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
bottom: false,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
if (widget.controller.lastError != null)
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
color: const Color(0xFFF8ECE6),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.controller.lastError!,
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
if (!widget.controller.storageReady)
|
||||||
|
TextButton(
|
||||||
|
onPressed: widget.controller.restoreBackup,
|
||||||
|
child: const Text('恢复最近备份'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: IndexedStack(
|
||||||
|
index: _tab,
|
||||||
|
children: [
|
||||||
|
TimetablePage(controller: widget.controller),
|
||||||
|
ProfilePage(
|
||||||
|
controller: widget.controller,
|
||||||
|
onLogin: _school,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 16 + MediaQuery.paddingOf(context).bottom,
|
||||||
|
child: Center(
|
||||||
|
child: GlassDock(
|
||||||
|
selectedIndex: _tab,
|
||||||
|
onSelected: (index) => setState(() => _tab = index),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,8 +18,9 @@ class ImportDraft {
|
|||||||
class JsonScheduleImport {
|
class JsonScheduleImport {
|
||||||
static ImportDraft parse(String text) {
|
static ImportDraft parse(String text) {
|
||||||
try {
|
try {
|
||||||
if (utf8.encode(text).length > 2 * 1024 * 1024)
|
if (utf8.encode(text).length > 2 * 1024 * 1024) {
|
||||||
throw const FormatException('文件不能超过 2 MB');
|
throw const FormatException('文件不能超过 2 MB');
|
||||||
|
}
|
||||||
final json = jsonDecode(text);
|
final json = jsonDecode(text);
|
||||||
if (json is! Map ||
|
if (json is! Map ||
|
||||||
json['schemaVersion'] != 1 ||
|
json['schemaVersion'] != 1 ||
|
||||||
@@ -40,8 +41,9 @@ class JsonScheduleImport {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
if (courses.map((c) => c.id).toSet().length != courses.length)
|
if (courses.map((c) => c.id).toSet().length != courses.length) {
|
||||||
throw const FormatException('课程标识重复');
|
throw const FormatException('课程标识重复');
|
||||||
|
}
|
||||||
return ImportDraft(
|
return ImportDraft(
|
||||||
courses: List.unmodifiable(courses),
|
courses: List.unmodifiable(courses),
|
||||||
explicitlyEmpty: json['explicitlyEmpty'] == true,
|
explicitlyEmpty: json['explicitlyEmpty'] == true,
|
||||||
|
|||||||
@@ -79,8 +79,9 @@ class AppSnapshot {
|
|||||||
}
|
}
|
||||||
final s = Map<String, dynamic>.from(json['settings'] as Map);
|
final s = Map<String, dynamic>.from(json['settings'] as Map);
|
||||||
final minutes = s['reminderMinutes'] as int;
|
final minutes = s['reminderMinutes'] as int;
|
||||||
if (![5, 10, 15, 30, 60].contains(minutes))
|
if (![5, 10, 15, 30, 60].contains(minutes)) {
|
||||||
throw const FormatException('提醒时间无效');
|
throw const FormatException('提醒时间无效');
|
||||||
|
}
|
||||||
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 sem = s['semester'];
|
final sem = s['semester'];
|
||||||
|
|||||||
@@ -49,8 +49,9 @@ class Course {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final rawWeeks = json['weeks'];
|
final rawWeeks = json['weeks'];
|
||||||
if (rawWeeks is! List || rawWeeks.any((w) => w is! int))
|
if (rawWeeks is! List || rawWeeks.any((w) => w is! int)) {
|
||||||
throw const FormatException('周次必须是整数数组');
|
throw const FormatException('周次必须是整数数组');
|
||||||
|
}
|
||||||
return Course(
|
return Course(
|
||||||
id: text('id'),
|
id: text('id'),
|
||||||
name: text('name'),
|
name: text('name'),
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../app/app_controller.dart';
|
||||||
|
import '../../app/app_theme.dart';
|
||||||
|
import '../../data/json_schedule_import.dart';
|
||||||
|
import '../../domain/course.dart';
|
||||||
|
|
||||||
|
class ImportPreview extends StatefulWidget {
|
||||||
|
const ImportPreview({
|
||||||
|
super.key,
|
||||||
|
required this.draft,
|
||||||
|
required this.controller,
|
||||||
|
});
|
||||||
|
final ImportDraft draft;
|
||||||
|
final AppController controller;
|
||||||
|
@override
|
||||||
|
State<ImportPreview> createState() => _ImportPreviewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImportPreviewState extends State<ImportPreview> {
|
||||||
|
bool _clearConfirmed = false, _busy = false;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final draft = widget.draft, empty = draft.courses.isEmpty;
|
||||||
|
final allowed =
|
||||||
|
!_busy && (!empty || (draft.explicitlyEmpty && _clearConfirmed));
|
||||||
|
return SafeArea(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 8, 24, 28),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'确认你的课表',
|
||||||
|
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'${draft.courses.length} 条课程安排 · ${draft.source == CourseSource.demo ? '示例体验' : '导入预览'}',
|
||||||
|
style: const TextStyle(color: river),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
if (empty)
|
||||||
|
Text(draft.explicitlyEmpty ? '此操作会清空当前课表。' : '没有识别到课程,当前课表保持不变。'),
|
||||||
|
if (empty && draft.explicitlyEmpty)
|
||||||
|
CheckboxListTile(
|
||||||
|
value: _clearConfirmed,
|
||||||
|
onChanged: (v) => setState(() => _clearConfirmed = v ?? false),
|
||||||
|
title: const Text('确认清空当前课表'),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
for (final c in draft.courses)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 10),
|
||||||
|
child: SurfaceCard(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
c.name,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 5),
|
||||||
|
Text(
|
||||||
|
'${c.teacher} · ${c.room}',
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'周${const ['一', '二', '三', '四', '五', '六', '日'][c.weekday - 1]} · 第 ${c.startPeriod}–${c.endPeriod} 节',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: ink.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.controller.snapshot.settings.semester == null &&
|
||||||
|
draft.source != CourseSource.demo)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: 16),
|
||||||
|
child: Text(
|
||||||
|
'导入后请在设置中选择真实学期开始日,课表与提醒才会显示。',
|
||||||
|
style: TextStyle(fontSize: 12, height: 1.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: allowed
|
||||||
|
? () async {
|
||||||
|
setState(() => _busy = true);
|
||||||
|
await widget.controller.applyImport(
|
||||||
|
draft,
|
||||||
|
confirmedEmpty: _clearConfirmed,
|
||||||
|
);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
if (widget.controller.lastError == null) {
|
||||||
|
Navigator.pop(context, true);
|
||||||
|
} else {
|
||||||
|
setState(() => _busy = false);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(widget.controller.lastError!),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
child: Text(
|
||||||
|
_busy
|
||||||
|
? '正在保存…'
|
||||||
|
: draft.source == CourseSource.demo
|
||||||
|
? '开始体验'
|
||||||
|
: '确认导入',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Center(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: _busy ? null : () => Navigator.pop(context, false),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../app/app_controller.dart';
|
||||||
|
import '../../app/app_theme.dart';
|
||||||
|
import '../../data/json_schedule_import.dart';
|
||||||
|
import 'settings_page.dart';
|
||||||
|
import 'import_preview.dart';
|
||||||
|
|
||||||
|
class ProfilePage extends StatelessWidget {
|
||||||
|
const ProfilePage({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
required this.onLogin,
|
||||||
|
this.authStatus = '未登录',
|
||||||
|
this.onLogout,
|
||||||
|
});
|
||||||
|
final AppController controller;
|
||||||
|
final VoidCallback onLogin;
|
||||||
|
final VoidCallback? onLogout;
|
||||||
|
final String authStatus;
|
||||||
|
Future<void> _import(BuildContext context) async {
|
||||||
|
try {
|
||||||
|
final file = await FilePicker.pickFile(
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: ['json'],
|
||||||
|
);
|
||||||
|
if (file == null) return;
|
||||||
|
final bytes = BytesBuilder(copy: false);
|
||||||
|
var count = 0;
|
||||||
|
await for (final chunk in file.readAsByteStream()) {
|
||||||
|
count += chunk.length;
|
||||||
|
if (count > 2 * 1024 * 1024) {
|
||||||
|
throw const FormatException('课表文件不能超过 2 MB');
|
||||||
|
}
|
||||||
|
bytes.add(chunk);
|
||||||
|
}
|
||||||
|
final draft = JsonScheduleImport.parse(utf8.decode(bytes.takeBytes()));
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await showModalBottomSheet<bool>(
|
||||||
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: canvas,
|
||||||
|
builder: (_) => ImportPreview(draft: draft, controller: controller),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text('导入未完成:$error')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final imported = controller.snapshot.importMetadata;
|
||||||
|
return ListView(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
22,
|
||||||
|
26,
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 7),
|
||||||
|
Text(
|
||||||
|
'把时间,留给真正重要的事。',
|
||||||
|
style: TextStyle(fontSize: 13, color: ink.withValues(alpha: 0.5)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 26),
|
||||||
|
SurfaceCard(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 55,
|
||||||
|
height: 55,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: river.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(19),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.person_outline_rounded,
|
||||||
|
size: 30,
|
||||||
|
color: river,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'连接学校课表',
|
||||||
|
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 7),
|
||||||
|
Text(
|
||||||
|
'电子科技大学 · 研究生\n$authStatus',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
height: 1.8,
|
||||||
|
color: ink.withValues(alpha: 0.55),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: onLogin,
|
||||||
|
child: Text(onLogout == null ? '登录学校账户' : '打开学校课表'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (onLogout != null)
|
||||||
|
Center(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: onLogout,
|
||||||
|
child: const Text('退出学校登录'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
SurfaceCard(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${controller.snapshot.courses.length}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 30,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: river,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Text('已导入的课程安排', style: TextStyle(fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
imported == null ? '尚未导入' : '已在本机保存',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 7),
|
||||||
|
Text(
|
||||||
|
imported == null
|
||||||
|
? '离线查看,更安心'
|
||||||
|
: '${imported.importedAt.month} 月 ${imported.importedAt.day} 日导入',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: ink.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 25),
|
||||||
|
const Text(
|
||||||
|
'安排你的每一天',
|
||||||
|
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SurfaceCard(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 5),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_tile(
|
||||||
|
Icons.tune_rounded,
|
||||||
|
'课表设置',
|
||||||
|
'颜色、提醒、日历和学期',
|
||||||
|
() => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) => SettingsPage(controller: controller),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Divider(height: 1, color: ink.withValues(alpha: 0.06)),
|
||||||
|
_tile(
|
||||||
|
Icons.file_download_outlined,
|
||||||
|
'从文件导入',
|
||||||
|
'先预览,再替换课表',
|
||||||
|
() => _import(context),
|
||||||
|
),
|
||||||
|
Divider(height: 1, color: ink.withValues(alpha: 0.06)),
|
||||||
|
_tile(
|
||||||
|
Icons.auto_awesome_outlined,
|
||||||
|
'体验示例课表',
|
||||||
|
'无需登录,看看河畔的样子',
|
||||||
|
controller.showDemo,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 25),
|
||||||
|
Center(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'河畔Everyday',
|
||||||
|
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'今天也有值得期待的小事。',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: ink.withValues(alpha: 0.35),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _tile(
|
||||||
|
IconData icon,
|
||||||
|
String title,
|
||||||
|
String subtitle,
|
||||||
|
VoidCallback action,
|
||||||
|
) => ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: Icon(icon, color: river, size: 23),
|
||||||
|
title: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
subtitle,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
height: 1.6,
|
||||||
|
color: ink.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: Icon(
|
||||||
|
Icons.chevron_right_rounded,
|
||||||
|
color: ink.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
onTap: action,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import '../../app/app_controller.dart';
|
||||||
|
import '../../app/app_theme.dart';
|
||||||
|
import '../../domain/app_settings.dart';
|
||||||
|
import '../../domain/semester.dart';
|
||||||
|
import '../../services/notification_service.dart';
|
||||||
|
|
||||||
|
class SettingsPage extends StatefulWidget {
|
||||||
|
const SettingsPage({super.key, required this.controller});
|
||||||
|
final AppController controller;
|
||||||
|
@override
|
||||||
|
State<SettingsPage> createState() => _SettingsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SettingsPageState extends State<SettingsPage> {
|
||||||
|
late TextEditingController _weeks;
|
||||||
|
DateTime? _start;
|
||||||
|
String? _weeksError, _dateError;
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_start = widget.controller.snapshot.settings.semester?.startDate;
|
||||||
|
_weeks = TextEditingController(
|
||||||
|
text: '${widget.controller.snapshot.settings.semester?.totalWeeks ?? 20}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_weeks.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _calendar(bool enabled) async {
|
||||||
|
final c = widget.controller;
|
||||||
|
if (enabled) {
|
||||||
|
if (!await c.calendar.requestAccess()) {
|
||||||
|
if (mounted) _notice('请允许日历访问后重试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final calendars = await c.calendar.listWritableCalendars();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (calendars.isEmpty) {
|
||||||
|
_notice('手机中没有可写入的日历,请先在日历应用中添加日历');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final id = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => SimpleDialog(
|
||||||
|
title: const Text('选择手机日历'),
|
||||||
|
children: calendars
|
||||||
|
.map(
|
||||||
|
(cal) => SimpleDialogOption(
|
||||||
|
onPressed: () => Navigator.pop(context, cal.id),
|
||||||
|
child: Text(cal.name),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (id != null) {
|
||||||
|
await c.enableCalendarFromUserAction(id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final delete = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('关闭日历同步'),
|
||||||
|
content: const Text('是否删除河畔创建的课程日程?其他日程不会受影响。'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('保留日程'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('删除课程日程'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (delete != null) {
|
||||||
|
await c.disableCalendar(deleteEvents: delete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mounted && c.calendarSyncError != null) {
|
||||||
|
_notice(c.calendarSyncError!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _notice(String text) =>
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => AnimatedBuilder(
|
||||||
|
animation: widget.controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
final c = widget.controller, s = c.snapshot.settings;
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text(
|
||||||
|
'课表设置',
|
||||||
|
style: TextStyle(fontSize: 21, fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 40),
|
||||||
|
children: [
|
||||||
|
if (c.isDemo)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: 18),
|
||||||
|
child: Text(
|
||||||
|
'示例模式中,提醒与日历功能不会应用到示例课程。',
|
||||||
|
style: TextStyle(fontSize: 12, color: river),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_heading('课表配色'),
|
||||||
|
SurfaceCard(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Wrap(
|
||||||
|
spacing: 10,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
ChoiceChip(
|
||||||
|
label: const Text('多彩'),
|
||||||
|
selected: s.colorMode == ColorMode.multicolor,
|
||||||
|
onSelected: (_) => c.updateSettings(
|
||||||
|
s.copyWith(colorMode: ColorMode.multicolor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ChoiceChip(
|
||||||
|
label: const Text('单色'),
|
||||||
|
selected: s.colorMode == ColorMode.single,
|
||||||
|
onSelected: (_) => c.updateSettings(
|
||||||
|
s.copyWith(colorMode: ColorMode.single),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 15),
|
||||||
|
Wrap(
|
||||||
|
spacing: 14,
|
||||||
|
runSpacing: 12,
|
||||||
|
children: [
|
||||||
|
for (final color in [
|
||||||
|
0xFF348F91,
|
||||||
|
0xFF6E83B1,
|
||||||
|
0xFFB18A6E,
|
||||||
|
0xFF8C789E,
|
||||||
|
])
|
||||||
|
Semantics(
|
||||||
|
label: '选择课表颜色',
|
||||||
|
button: true,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => c.updateSettings(
|
||||||
|
s.copyWith(
|
||||||
|
singleColorArgb: color,
|
||||||
|
colorMode: ColorMode.single,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(30),
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Color(color),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: s.singleColorArgb == color
|
||||||
|
? ink
|
||||||
|
: Colors.transparent,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: s.singleColorArgb == color
|
||||||
|
? const Icon(
|
||||||
|
Icons.check_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 20,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
_heading('课前提醒'),
|
||||||
|
SurfaceCard(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SwitchListTile.adaptive(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('提醒我去上课', style: TextStyle(fontSize: 15)),
|
||||||
|
value: s.remindersEnabled,
|
||||||
|
onChanged: c.isDemo
|
||||||
|
? null
|
||||||
|
: (v) async {
|
||||||
|
if (v) {
|
||||||
|
await c.enableRemindersFromUserAction();
|
||||||
|
} else {
|
||||||
|
await c.updateSettings(
|
||||||
|
s.copyWith(remindersEnabled: false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 5),
|
||||||
|
Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 7,
|
||||||
|
children: [
|
||||||
|
for (final minutes in [5, 10, 15, 30, 60])
|
||||||
|
ChoiceChip(
|
||||||
|
label: Text('$minutes 分钟'),
|
||||||
|
selected: s.reminderMinutes == minutes,
|
||||||
|
onSelected: c.isDemo
|
||||||
|
? null
|
||||||
|
: (_) => c.updateSettings(
|
||||||
|
s.copyWith(reminderMinutes: minutes),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
c.reminderResult.message.isNotEmpty
|
||||||
|
? c.reminderResult.message
|
||||||
|
: c.reminderResult.status ==
|
||||||
|
ReminderStatus.permissionDenied
|
||||||
|
? '尚未允许通知权限,点击下方按钮开启。'
|
||||||
|
: '连续课程只在开始前提醒一次。',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
height: 1.6,
|
||||||
|
color: ink.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!c.isDemo && s.remindersEnabled)
|
||||||
|
TextButton(
|
||||||
|
onPressed: c.enableRemindersFromUserAction,
|
||||||
|
child: const Text('允许课前提醒'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
_heading('手机日历'),
|
||||||
|
SurfaceCard(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SwitchListTile.adaptive(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('导入手机日历', style: TextStyle(fontSize: 15)),
|
||||||
|
subtitle: const Text(
|
||||||
|
'按实际上课周次同步,不重复提醒',
|
||||||
|
style: TextStyle(fontSize: 11),
|
||||||
|
),
|
||||||
|
value: s.calendarEnabled,
|
||||||
|
onChanged: c.isDemo ? null : _calendar,
|
||||||
|
),
|
||||||
|
if (c.calendarSyncError != null)
|
||||||
|
Text(
|
||||||
|
c.calendarSyncError!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Color(0xFFB45D54),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
_heading('学期安排'),
|
||||||
|
SurfaceCard(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('学期开始日', style: TextStyle(fontSize: 15)),
|
||||||
|
subtitle: Text(
|
||||||
|
_start == null
|
||||||
|
? '请选择学校实际开学日期'
|
||||||
|
: '${_start!.year} 年 ${_start!.month} 月 ${_start!.day} 日',
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
trailing: const Icon(
|
||||||
|
Icons.date_range_outlined,
|
||||||
|
color: river,
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
final date = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _start ?? c.clock(),
|
||||||
|
firstDate: DateTime(2020),
|
||||||
|
lastDate: DateTime(2040),
|
||||||
|
);
|
||||||
|
if (date != null && mounted) {
|
||||||
|
setState(() => _start = date);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (_dateError != null)
|
||||||
|
Text(
|
||||||
|
_dateError!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Color(0xFFB45D54),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
key: const Key('semester-weeks'),
|
||||||
|
controller: _weeks,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
LengthLimitingTextInputFormatter(2),
|
||||||
|
],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: '学期总周数',
|
||||||
|
hintText: '1–30',
|
||||||
|
errorText: _weeksError,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final weeks = int.tryParse(_weeks.text);
|
||||||
|
setState(() {
|
||||||
|
_weeksError = weeks == null || weeks < 1 || weeks > 30
|
||||||
|
? '周数应为 1–30'
|
||||||
|
: null;
|
||||||
|
_dateError = _start == null ? '请先选择学期开始日' : null;
|
||||||
|
});
|
||||||
|
if (_weeksError != null || _dateError != null) return;
|
||||||
|
await c.updateSettings(
|
||||||
|
s.copyWith(
|
||||||
|
semester: Semester(
|
||||||
|
startDate: _start!,
|
||||||
|
totalWeeks: weeks!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
_notice(c.lastError ?? '学期设置已保存');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('保存学期设置'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'开始日所在的周一至周日为第 1 周。开始日前不会生成课程。',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
height: 1.6,
|
||||||
|
color: ink.withValues(alpha: 0.45),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Widget _heading(String text) => Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 4, bottom: 10),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../domain/course_occurrence.dart';
|
||||||
|
import '../../domain/lesson_times.dart';
|
||||||
|
import '../../app/app_theme.dart';
|
||||||
|
|
||||||
|
void showCourseDetails(BuildContext context, CourseOccurrence event) {
|
||||||
|
showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
showDragHandle: true,
|
||||||
|
backgroundColor: canvas,
|
||||||
|
builder: (context) => SafeArea(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 8, 24, 32),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
event.course.name,
|
||||||
|
style: const TextStyle(fontSize: 25, fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_line(Icons.person_outline_rounded, event.course.teacher),
|
||||||
|
_line(Icons.location_on_outlined, event.course.room),
|
||||||
|
_line(
|
||||||
|
Icons.schedule_rounded,
|
||||||
|
'${minuteLabel(event.start.hour * 60 + event.start.minute)}–${minuteLabel(event.end.hour * 60 + event.end.minute)} · 第 ${event.course.startPeriod}–${event.course.endPeriod} 节',
|
||||||
|
),
|
||||||
|
_line(
|
||||||
|
Icons.date_range_outlined,
|
||||||
|
'第 ${event.week} 周 · ${event.start.month} 月 ${event.start.day} 日',
|
||||||
|
),
|
||||||
|
_line(
|
||||||
|
Icons.repeat_rounded,
|
||||||
|
'上课周次:${(event.course.weeks.toList()..sort()).join('、')}',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('知道了'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _line(IconData icon, String text) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 21, color: river),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(text, style: const TextStyle(fontSize: 15, height: 1.5)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../app/app_controller.dart';
|
||||||
|
import '../../app/app_theme.dart';
|
||||||
|
import '../../domain/schedule_engine.dart';
|
||||||
|
import 'week_grid.dart';
|
||||||
|
|
||||||
|
class TimetablePage extends StatefulWidget {
|
||||||
|
const TimetablePage({super.key, required this.controller});
|
||||||
|
final AppController controller;
|
||||||
|
@override
|
||||||
|
State<TimetablePage> createState() => _TimetablePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TimetablePageState extends State<TimetablePage> {
|
||||||
|
int? _week;
|
||||||
|
bool? _wasDemo;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final c = widget.controller, term = c.visibleSettings.semester;
|
||||||
|
final today = ScheduleEngine.schoolDate(c.clock());
|
||||||
|
if (_wasDemo != c.isDemo) {
|
||||||
|
_week = null;
|
||||||
|
_wasDemo = c.isDemo;
|
||||||
|
}
|
||||||
|
final current = term == null
|
||||||
|
? 1
|
||||||
|
: (ScheduleEngine.weekNumber(term, today) ?? 1);
|
||||||
|
final week = (_week ?? current).clamp(1, term?.totalWeeks ?? 1);
|
||||||
|
final monday = term == null
|
||||||
|
? today.subtract(Duration(days: today.weekday - 1))
|
||||||
|
: term.firstMonday.add(Duration(days: (week - 1) * 7));
|
||||||
|
final sunday = monday.add(const Duration(days: 6));
|
||||||
|
return ListView(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
18,
|
||||||
|
20,
|
||||||
|
18,
|
||||||
|
140 + MediaQuery.paddingOf(context).bottom,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'HEPAN / EVERYDAY',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
letterSpacing: 2.2,
|
||||||
|
color: river.withValues(alpha: 0.8),
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
SurfaceCard(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: term != null && week > 1
|
||||||
|
? () => setState(() => _week = week - 1)
|
||||||
|
: null,
|
||||||
|
icon: const Icon(Icons.chevron_left_rounded),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
term == null ? '本周' : '第 $week 周',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'${monday.month}.${monday.day.toString().padLeft(2, '0')} – ${sunday.month}.${sunday.day.toString().padLeft(2, '0')}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: ink.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: term != null && week < term.totalWeeks
|
||||||
|
? () => setState(() => _week = week + 1)
|
||||||
|
: null,
|
||||||
|
icon: const Icon(Icons.chevron_right_rounded),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => setState(() => _week = null),
|
||||||
|
child: const Text('本周'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
if (c.isDemo)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Wrap(
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 7,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF3E9D6),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'示例课表',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Color(0xFF94733D),
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
'不会生成提醒或日历事件',
|
||||||
|
style: TextStyle(fontSize: 10, color: Color(0xFF85928E)),
|
||||||
|
),
|
||||||
|
TextButton(onPressed: c.leaveDemo, child: const Text('退出示例')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (term != null) ...[
|
||||||
|
WeekGrid(
|
||||||
|
events: ScheduleEngine.occurrences(
|
||||||
|
c.visibleCourses,
|
||||||
|
term,
|
||||||
|
).where((e) => e.week == week).toList(),
|
||||||
|
monday: monday,
|
||||||
|
today: today,
|
||||||
|
settings: c.visibleSettings,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'左右滑动查看整周 · 点击课程查看详情',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 11, color: ink.withValues(alpha: 0.35)),
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
const SizedBox(height: 38),
|
||||||
|
const Icon(
|
||||||
|
Icons.calendar_month_outlined,
|
||||||
|
size: 66,
|
||||||
|
color: Color(0xFFB9D6D3),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
const Text(
|
||||||
|
'让课表,陪你开启每一天',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'在「我的」设置学期并导入课程,\n或先看看河畔的示例课表。',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.8,
|
||||||
|
color: ink.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 25),
|
||||||
|
Center(
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: c.showDemo,
|
||||||
|
icon: const Icon(Icons.auto_awesome_outlined, size: 18),
|
||||||
|
label: const Text('体验示例课表'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../domain/schedule_engine.dart';
|
||||||
|
import '../../domain/app_settings.dart';
|
||||||
|
import '../../app/app_theme.dart';
|
||||||
|
import 'course_details.dart';
|
||||||
|
|
||||||
|
class WeekGrid extends StatelessWidget {
|
||||||
|
const WeekGrid({
|
||||||
|
super.key,
|
||||||
|
required this.events,
|
||||||
|
required this.monday,
|
||||||
|
required this.today,
|
||||||
|
required this.settings,
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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};
|
||||||
|
var grew = true;
|
||||||
|
while (grew) {
|
||||||
|
grew = false;
|
||||||
|
for (final other in values) {
|
||||||
|
if (!connected.contains(other) &&
|
||||||
|
connected.any((known) => ScheduleEngine.overlaps(known, other))) {
|
||||||
|
connected.add(other);
|
||||||
|
grew = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)),
|
||||||
|
),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../app/app_theme.dart';
|
||||||
|
|
||||||
|
class GlassDock extends StatelessWidget {
|
||||||
|
const GlassDock({
|
||||||
|
super.key,
|
||||||
|
required this.selectedIndex,
|
||||||
|
required this.onSelected,
|
||||||
|
});
|
||||||
|
final int selectedIndex;
|
||||||
|
final ValueChanged<int> onSelected;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(40),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: ink.withValues(alpha: 0.13),
|
||||||
|
blurRadius: 28,
|
||||||
|
offset: const Offset(0, 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white.withValues(alpha: 0.95),
|
||||||
|
width: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
_item(0, Icons.calendar_month_rounded, '课表'),
|
||||||
|
_item(1, Icons.person_outline_rounded, '我的'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,6 +35,32 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
tearDown(() => controller.dispose());
|
tearDown(() => controller.dispose());
|
||||||
|
test('a new successful demo action clears a stale save error', () async {
|
||||||
|
await controller.initialize();
|
||||||
|
store.failSave = true;
|
||||||
|
await controller.updateSettings(
|
||||||
|
controller.snapshot.settings.copyWith(reminderMinutes: 30),
|
||||||
|
);
|
||||||
|
expect(controller.lastError, isNotNull);
|
||||||
|
await controller.showDemo();
|
||||||
|
expect(controller.lastError, isNull);
|
||||||
|
});
|
||||||
|
test(
|
||||||
|
'refresh after corrupt storage never reconciles an empty timetable',
|
||||||
|
() async {
|
||||||
|
final damaged = AppController(
|
||||||
|
DamagedStore(),
|
||||||
|
notifications,
|
||||||
|
calendar,
|
||||||
|
() => DateTime.utc(2026, 9, 8),
|
||||||
|
);
|
||||||
|
await damaged.initialize();
|
||||||
|
await damaged.refreshServices();
|
||||||
|
expect(notifications.reconciliations, 0);
|
||||||
|
expect(damaged.storageReady, isFalse);
|
||||||
|
damaged.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
test('failed saving does not change currently visible settings', () async {
|
test('failed saving does not change currently visible settings', () async {
|
||||||
await controller.initialize();
|
await controller.initialize();
|
||||||
store.failSave = true;
|
store.failSave = true;
|
||||||
@@ -90,3 +116,8 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class DamagedStore extends MemoryStore {
|
||||||
|
@override
|
||||||
|
Future<AppSnapshot?> load() async => throw const FormatException('damaged');
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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/hepan_app.dart';
|
||||||
|
import 'package:hepan_everyday/app/app_controller.dart';
|
||||||
|
|
||||||
|
import '../support/fakes.dart';
|
||||||
|
|
||||||
|
AppController uiController() => AppController(
|
||||||
|
MemoryStore(),
|
||||||
|
FakeNotifications(),
|
||||||
|
FakeCalendar(),
|
||||||
|
() => DateTime.utc(2026, 10, 12),
|
||||||
|
);
|
||||||
|
void main() {
|
||||||
|
setUpAll(tzdata.initializeTimeZones);
|
||||||
|
testWidgets('two icon tabs navigate to profile and timetable', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final controller = uiController();
|
||||||
|
await controller.initialize();
|
||||||
|
await tester.pumpWidget(HepanApp(controller: controller));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.bySemanticsLabel('我的'), findsOneWidget);
|
||||||
|
await tester.tap(find.bySemanticsLabel('我的'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('登录学校账户'), findsOneWidget);
|
||||||
|
expect(find.text('课表设置'), findsOneWidget);
|
||||||
|
await tester.tap(find.bySemanticsLabel('课表'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('体验示例课表'), findsOneWidget);
|
||||||
|
controller.dispose();
|
||||||
|
});
|
||||||
|
testWidgets('demo reaches Sunday and the last lesson on a small display', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
tester.view.physicalSize = const Size(360, 800);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
final controller = uiController();
|
||||||
|
await controller.initialize();
|
||||||
|
await controller.showDemo();
|
||||||
|
await tester.pumpWidget(HepanApp(controller: controller));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('示例课表'), findsOneWidget);
|
||||||
|
expect(find.text('11'), findsOneWidget);
|
||||||
|
await tester.ensureVisible(find.text('周一'));
|
||||||
|
await tester.drag(find.text('周一'), const Offset(-650, 0));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(find.text('周日'), findsOneWidget);
|
||||||
|
expect(tester.getCenter(find.text('周日')).dx, lessThan(360));
|
||||||
|
controller.dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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/hepan_app.dart';
|
||||||
|
|
||||||
|
import 'navigation_test.dart' show uiController;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpAll(tzdata.initializeTimeZones);
|
||||||
|
testWidgets(
|
||||||
|
'invalid semester weeks are rejected without changing saved data',
|
||||||
|
(tester) async {
|
||||||
|
final controller = uiController();
|
||||||
|
await controller.initialize();
|
||||||
|
await tester.pumpWidget(HepanApp(controller: controller));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.bySemanticsLabel('我的'), findsOneWidget);
|
||||||
|
await tester.tap(find.bySemanticsLabel('我的'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.ensureVisible(find.text('课表设置'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('课表设置'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.scrollUntilVisible(
|
||||||
|
find.byKey(const Key('semester-weeks')),
|
||||||
|
300,
|
||||||
|
scrollable: find.byType(Scrollable).last,
|
||||||
|
);
|
||||||
|
await tester.enterText(find.byKey(const Key('semester-weeks')), '31');
|
||||||
|
await tester.ensureVisible(find.text('保存学期设置'));
|
||||||
|
await tester.tap(find.text('保存学期设置'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('周数应为 1–30'), findsOneWidget);
|
||||||
|
expect(controller.snapshot.settings.semester, isNull);
|
||||||
|
controller.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
testWidgets('two hundred percent font demo has no overflow', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(360, 800);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
tester.platformDispatcher.textScaleFactorTestValue = 2;
|
||||||
|
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||||
|
final controller = uiController();
|
||||||
|
await controller.initialize();
|
||||||
|
await controller.showDemo();
|
||||||
|
await tester.pumpWidget(HepanApp(controller: controller));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.scrollUntilVisible(
|
||||||
|
find.text('示例课表'),
|
||||||
|
250,
|
||||||
|
scrollable: find.byType(Scrollable).first,
|
||||||
|
);
|
||||||
|
expect(find.text('示例课表'), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
controller.dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:timezone/data/latest.dart' as tzdata;
|
||||||
|
import 'package:hepan_everyday/features/timetable/week_grid.dart';
|
||||||
|
import 'package:hepan_everyday/domain/schedule_engine.dart';
|
||||||
|
import 'package:hepan_everyday/domain/app_settings.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpAll(tzdata.initializeTimeZones);
|
||||||
|
testWidgets('transitive lesson conflicts never cover another course card', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final term = Semester(startDate: DateTime(2026, 9, 7), totalWeeks: 1);
|
||||||
|
Course c(String name, int first, int last) => Course(
|
||||||
|
id: name,
|
||||||
|
name: name,
|
||||||
|
teacher: 'T',
|
||||||
|
room: 'R',
|
||||||
|
weekday: 1,
|
||||||
|
startPeriod: first,
|
||||||
|
endPeriod: last,
|
||||||
|
weeks: {1},
|
||||||
|
source: CourseSource.file,
|
||||||
|
);
|
||||||
|
final events = ScheduleEngine.occurrences([
|
||||||
|
c('A', 1, 2),
|
||||||
|
c('B', 2, 3),
|
||||||
|
c('C', 3, 4),
|
||||||
|
], term);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
child: WeekGrid(
|
||||||
|
events: events,
|
||||||
|
monday: term.firstMonday,
|
||||||
|
today: term.startDate,
|
||||||
|
settings: const AppSettings(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final a = tester.getRect(find.bySemanticsLabel('A,T,R'));
|
||||||
|
final b = tester.getRect(find.bySemanticsLabel('B,T,R'));
|
||||||
|
final cRect = tester.getRect(find.bySemanticsLabel('C,T,R'));
|
||||||
|
expect(a.right, lessThanOrEqualTo(b.left));
|
||||||
|
expect(b.right, lessThanOrEqualTo(cRect.left));
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user