Files
TPSchedule/mobile/lib/features/profile/settings_page.dart
T

517 lines
19 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import 'reminder_tools.dart';
class SettingsPage extends StatefulWidget {
const SettingsPage({
super.key,
required this.controller,
this.focusSemester = false,
});
final AppController controller;
final bool focusSemester;
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
late TextEditingController _weeks;
DateTime? _start;
double? _courseHeightDraft;
bool _savingHeight = false;
String? _weeksError, _dateError;
final _scroll = ScrollController();
@override
void initState() {
super.initState();
_start = widget.controller.snapshot.settings.semester?.startDate;
_weeks = TextEditingController(
text: '${widget.controller.snapshot.settings.semester?.totalWeeks ?? 20}',
);
if (widget.focusSemester) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _scroll.hasClients) {
_scroll.jumpTo(_scroll.position.maxScrollExtent);
}
});
}
}
@override
void dispose() {
_weeks.dispose();
_scroll.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('是否删除 TPSchedule 创建的课程日程?其他日程不会受影响。'),
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!);
}
}
Future<void> _saveCourseHeight(double value) async {
setState(() => _savingHeight = true);
await widget.controller.updateSettings(
(current) => current.copyWith(courseHeightScale: value),
);
if (!mounted) return;
setState(() {
_courseHeightDraft = null;
_savingHeight = false;
});
if (widget.controller.lastError != null) {
_notice(widget.controller.lastError!);
}
}
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(
controller: _scroll,
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(
(current) =>
current.copyWith(colorMode: ColorMode.multicolor),
),
),
ChoiceChip(
label: const Text('单色'),
selected: s.colorMode == ColorMode.single,
onSelected: (_) => c.updateSettings(
(current) =>
current.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(
(current) => current.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: [
Row(
children: [
Expanded(
child: Text(
'${((_courseHeightDraft ?? s.courseHeightScale) * 100).round()}%',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: river,
),
),
),
TextButton(
onPressed: _savingHeight
? null
: () => _saveCourseHeight(
AppSettings.defaultCourseHeightScale,
),
child: const Text('恢复默认'),
),
],
),
Slider(
key: const Key('course-height-slider'),
value: _courseHeightDraft ?? s.courseHeightScale,
min: AppSettings.minCourseHeightScale,
max: AppSettings.maxCourseHeightScale,
divisions: 24,
label:
'${((_courseHeightDraft ?? s.courseHeightScale) * 100).round()}%',
semanticFormatterCallback: (value) =>
'${(value * 100).round()}%',
onChanged: _savingHeight
? null
: (value) => setState(() => _courseHeightDraft = value),
onChangeEnd: _saveCourseHeight,
),
const Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('较短', style: TextStyle(fontSize: 11)),
Text('较长', style: TextStyle(fontSize: 11)),
],
),
const SizedBox(height: 10),
Text(
'统一调整课程条长度,老师和教室始终完整显示。',
style: TextStyle(
fontSize: 11,
height: 1.6,
color: ink.withValues(alpha: 0.55),
),
),
],
),
),
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(
(current) =>
current.copyWith(remindersEnabled: false),
);
}
},
),
const SizedBox(height: 5),
Wrap(
spacing: 6,
runSpacing: 7,
children: [
for (final minutes in AppSettings.allowedReminderMinutes)
FilterChip(
label: Text('$minutes 分钟'),
selected: s.reminderOffsets.contains(minutes),
onSelected: c.isDemo
? null
: (selected) {
if (!selected &&
s.reminderOffsets.length == 1) {
_notice('请至少保留一个提醒时间');
return;
}
c.updateSettings(
(current) => current.copyWith(
reminderOffsets: [
for (final value
in current.reminderOffsets)
if (value != minutes) value,
if (selected) 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),
if (!c.isDemo) ReminderTools(controller: c),
if (!c.isDemo) 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(
(current) => current.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),
),
);
}