diff --git a/mobile/lib/app/app_controller.dart b/mobile/lib/app/app_controller.dart index 9ab91df..001075a 100644 --- a/mobile/lib/app/app_controller.dart +++ b/mobile/lib/app/app_controller.dart @@ -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 _run(Future 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 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 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 refreshServices() => _run(_reconcile); Future _reconcile() async { + if (!storageReady) { + return; + } final settings = snapshot.settings; final occurrences = settings.semester == null ? [] diff --git a/mobile/lib/app/app_theme.dart b/mobile/lib/app/app_theme.dart new file mode 100644 index 0000000..0da480d --- /dev/null +++ b/mobile/lib/app/app_theme.dart @@ -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(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), + ); +} diff --git a/mobile/lib/app/hepan_app.dart b/mobile/lib/app/hepan_app.dart new file mode 100644 index 0000000..f20dcb3 --- /dev/null +++ b/mobile/lib/app/hepan_app.dart @@ -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(builder: (_) => widget.schoolBuilder!()), + ); + } else { + showDialog( + 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), + ), + ), + ), + ], + ), + ), + ), + ), + ); +} diff --git a/mobile/lib/data/json_schedule_import.dart b/mobile/lib/data/json_schedule_import.dart index ae0fb4c..1cbce10 100644 --- a/mobile/lib/data/json_schedule_import.dart +++ b/mobile/lib/data/json_schedule_import.dart @@ -18,8 +18,9 @@ class ImportDraft { class JsonScheduleImport { static ImportDraft parse(String text) { try { - if (utf8.encode(text).length > 2 * 1024 * 1024) + if (utf8.encode(text).length > 2 * 1024 * 1024) { throw const FormatException('文件不能超过 2 MB'); + } final json = jsonDecode(text); if (json is! Map || json['schemaVersion'] != 1 || @@ -40,8 +41,9 @@ class JsonScheduleImport { ), ) .toList(); - if (courses.map((c) => c.id).toSet().length != courses.length) + if (courses.map((c) => c.id).toSet().length != courses.length) { throw const FormatException('课程标识重复'); + } return ImportDraft( courses: List.unmodifiable(courses), explicitlyEmpty: json['explicitlyEmpty'] == true, diff --git a/mobile/lib/domain/app_snapshot.dart b/mobile/lib/domain/app_snapshot.dart index b278218..22b137c 100644 --- a/mobile/lib/domain/app_snapshot.dart +++ b/mobile/lib/domain/app_snapshot.dart @@ -79,8 +79,9 @@ class AppSnapshot { } final s = Map.from(json['settings'] as Map); 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('提醒时间无效'); + } final color = s['singleColorArgb'] as int; if (color < 0 || color > 0xFFFFFFFF) throw const FormatException('颜色无效'); final sem = s['semester']; diff --git a/mobile/lib/domain/course.dart b/mobile/lib/domain/course.dart index 79f1faf..d1b95fb 100644 --- a/mobile/lib/domain/course.dart +++ b/mobile/lib/domain/course.dart @@ -49,8 +49,9 @@ class Course { } 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('周次必须是整数数组'); + } return Course( id: text('id'), name: text('name'), diff --git a/mobile/lib/features/profile/import_preview.dart b/mobile/lib/features/profile/import_preview.dart new file mode 100644 index 0000000..2ebe404 --- /dev/null +++ b/mobile/lib/features/profile/import_preview.dart @@ -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 createState() => _ImportPreviewState(); +} + +class _ImportPreviewState extends State { + 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('取消'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/profile/profile_page.dart b/mobile/lib/features/profile/profile_page.dart new file mode 100644 index 0000000..497269e --- /dev/null +++ b/mobile/lib/features/profile/profile_page.dart @@ -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 _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( + 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( + 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, + ); +} diff --git a/mobile/lib/features/profile/settings_page.dart b/mobile/lib/features/profile/settings_page.dart new file mode 100644 index 0000000..8eb8799 --- /dev/null +++ b/mobile/lib/features/profile/settings_page.dart @@ -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 createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + 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 _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( + 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( + 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), + ), + ); +} diff --git a/mobile/lib/features/timetable/course_details.dart b/mobile/lib/features/timetable/course_details.dart new file mode 100644 index 0000000..b454c05 --- /dev/null +++ b/mobile/lib/features/timetable/course_details.dart @@ -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( + 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)), + ), + ], + ), +); diff --git a/mobile/lib/features/timetable/timetable_page.dart b/mobile/lib/features/timetable/timetable_page.dart new file mode 100644 index 0000000..fc8e846 --- /dev/null +++ b/mobile/lib/features/timetable/timetable_page.dart @@ -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 createState() => _TimetablePageState(); +} + +class _TimetablePageState extends State { + 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('体验示例课表'), + ), + ), + ], + ], + ); + } +} diff --git a/mobile/lib/features/timetable/week_grid.dart b/mobile/lib/features/timetable/week_grid.dart new file mode 100644 index 0000000..ac633cb --- /dev/null +++ b/mobile/lib/features/timetable/week_grid.dart @@ -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 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 _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 values, + double width, + double rowHeight, + double Function(int) top, + ) { + final connected = {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, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/widgets/glass_dock.dart b/mobile/lib/widgets/glass_dock.dart new file mode 100644 index 0000000..1547f0e --- /dev/null +++ b/mobile/lib/widgets/glass_dock.dart @@ -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 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), + ), + ), + ), + ); +} diff --git a/mobile/test/app/app_controller_test.dart b/mobile/test/app/app_controller_test.dart index 074eb85..e610f8a 100644 --- a/mobile/test/app/app_controller_test.dart +++ b/mobile/test/app/app_controller_test.dart @@ -35,6 +35,32 @@ void main() { ); }); 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 { await controller.initialize(); store.failSave = true; @@ -90,3 +116,8 @@ void main() { }, ); } + +class DamagedStore extends MemoryStore { + @override + Future load() async => throw const FormatException('damaged'); +} diff --git a/mobile/test/widgets/navigation_test.dart b/mobile/test/widgets/navigation_test.dart new file mode 100644 index 0000000..0699ad8 --- /dev/null +++ b/mobile/test/widgets/navigation_test.dart @@ -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(); + }); +} diff --git a/mobile/test/widgets/settings_test.dart b/mobile/test/widgets/settings_test.dart new file mode 100644 index 0000000..c3bdd65 --- /dev/null +++ b/mobile/test/widgets/settings_test.dart @@ -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(); + }); +} diff --git a/mobile/test/widgets/timetable_test.dart b/mobile/test/widgets/timetable_test.dart new file mode 100644 index 0000000..ebccbca --- /dev/null +++ b/mobile/test/widgets/timetable_test.dart @@ -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)); + }); +}