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_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>[]
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user