feat: add two-tab timetable UI and glass dock
This commit is contained in:
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user