feat: add two-tab timetable UI and glass dock

This commit is contained in:
Codex
2026-10-12 23:33:33 +08:00
parent 808af007a2
commit 8a98d49d12
17 changed files with 1912 additions and 9 deletions
+14 -5
View File
@@ -2,7 +2,6 @@ import 'package:flutter/foundation.dart';
import '../domain/app_snapshot.dart';
import '../domain/app_settings.dart';
import '../domain/course.dart';
import '../data/snapshot_store.dart';
import '../data/json_schedule_import.dart';
import '../services/notification_service.dart';
@@ -29,6 +28,9 @@ class AppController extends ChangeNotifier {
isDemo ? _demoSettings! : snapshot.settings;
Future<void> _run(Future<void> Function() operation) {
final next = _queue.then((_) async {
if (storageReady) {
lastError = null;
}
try {
await operation();
} catch (error) {
@@ -84,14 +86,16 @@ class AppController extends ChangeNotifier {
isDemo = false;
});
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('提醒时间无效');
}
await _save(snapshot.copyWith(settings: value));
if (isDemo)
if (isDemo) {
_demoSettings = _demoSettings!.copyWith(
colorMode: value.colorMode,
singleColorArgb: value.singleColorArgb,
);
}
await _reconcile();
});
Future<void> applyImport(
@@ -101,8 +105,9 @@ class AppController extends ChangeNotifier {
if (draft.courses.isEmpty && (!draft.explicitlyEmpty || !confirmedEmpty)) {
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('课程标识重复');
}
if (draft.source == CourseSource.demo) {
_demo = draft.courses;
isDemo = true;
@@ -117,8 +122,9 @@ class AppController extends ChangeNotifier {
);
return;
}
if (draft.courses.any((c) => c.source != draft.source))
if (draft.courses.any((c) => c.source != draft.source)) {
throw const FormatException('课程来源不一致');
}
await _save(
snapshot.copyWith(
courses: draft.courses,
@@ -195,6 +201,9 @@ class AppController extends ChangeNotifier {
});
Future<void> refreshServices() => _run(_reconcile);
Future<void> _reconcile() async {
if (!storageReady) {
return;
}
final settings = snapshot.settings;
final occurrences = settings.semester == null
? <CourseOccurrence>[]
+78
View File
@@ -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),
);
}
+145
View File
@@ -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),
),
),
),
],
),
),
),
),
);
}