403 lines
15 KiB
Dart
403 lines
15 KiB
Dart
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 {
|
||
try {
|
||
await _calendarAction(enabled);
|
||
} catch (_) {
|
||
if (mounted) {
|
||
_notice('日历暂时不可用,请检查权限后重试');
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _calendarAction(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),
|
||
),
|
||
),
|
||
if (c.snapshot.calendarCleanupPending)
|
||
TextButton(
|
||
onPressed: c.retryCalendarCleanup,
|
||
child: const Text('重试清理课程日程'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
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),
|
||
),
|
||
);
|
||
}
|