Files
TPSchedule/mobile/lib/features/feedback/feedback_page.dart
T

528 lines
22 KiB
Dart

import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import '../../app/app_theme.dart';
import '../../services/diagnostic_log.dart';
import '../../services/school_page_diagnostic.dart';
import 'feedback_submission.dart';
typedef FeedbackAttachmentPicker = Future<FeedbackAttachment?> Function();
class FeedbackPage extends StatefulWidget {
const FeedbackPage({
super.key,
this.sender = const UnconfiguredFeedbackSender(),
this.pickAttachment,
this.limits = FeedbackLimits.defaults,
});
final FeedbackSender sender;
final FeedbackAttachmentPicker? pickAttachment;
final FeedbackLimits limits;
@override
State<FeedbackPage> createState() => _FeedbackPageState();
}
class _FeedbackPageState extends State<FeedbackPage> {
final _form = GlobalKey<FormState>();
final _message = TextEditingController();
final _contact = TextEditingController();
final _school = TextEditingController();
final _attachments = <FeedbackAttachment>[];
var _kind = FeedbackKind.problem;
var _submitting = false;
var _picking = false;
var _success = false;
var _uncertain = false;
var _includeLogs = false;
var _includeHtml = false;
String? _error;
FeedbackSubmission? _pendingSubmission;
@override
void dispose() {
_message.dispose();
_contact.dispose();
_school.dispose();
super.dispose();
}
void _changed([String? value]) {
setState(() {
_pendingSubmission = null;
_error = null;
});
}
Future<void> _addAttachment() async {
if (_picking || _submitting) return;
if (_attachments.length >= widget.limits.maxAttachments) {
setState(() => _error = '最多添加 ${widget.limits.maxAttachments} 个附件');
return;
}
setState(() => _picking = true);
try {
FeedbackAttachment? attachment;
if (widget.pickAttachment != null) {
attachment = await widget.pickAttachment!();
} else {
final file = await FilePicker.pickFile(
type: FileType.custom,
allowedExtensions: FeedbackAttachment.supportedTypes.keys.toList(),
);
if (file != null) {
final knownSize = file.lengthSync();
if (knownSize != null && knownSize > widget.limits.maxFileBytes) {
throw FeedbackException(
'单个附件不能超过 ${_mb(widget.limits.maxFileBytes)} MB',
);
}
attachment = await FeedbackAttachment.read(
name: file.name,
stream: file.readAsByteStream(),
maxBytes: widget.limits.maxFileBytes,
);
}
}
if (!mounted || attachment == null) return;
FeedbackSubmission.validateAttachments([
..._attachments,
attachment,
], widget.limits);
setState(() {
_attachments.add(attachment!);
_pendingSubmission = null;
_error = null;
});
} on FeedbackException catch (error) {
if (mounted) setState(() => _error = error.message);
} catch (_) {
if (mounted) setState(() => _error = '无法读取附件,请重新选择');
} finally {
if (mounted) setState(() => _picking = false);
}
}
Future<void> _submit() async {
if (_submitting || _picking) return;
if (!_uncertain && !(_form.currentState?.validate() ?? false)) return;
FocusScope.of(context).unfocus();
setState(() => _submitting = true);
try {
final html =
_pendingSubmission == null &&
_kind == FeedbackKind.problem &&
_includeHtml
? SchoolPageDiagnostic.instance.export()
: null;
if (_pendingSubmission == null &&
_kind == FeedbackKind.problem &&
_includeHtml &&
html == null) {
throw const FeedbackException(
'最近10分钟没有可用的学校导入页面。请返回本科或研究生学校页面再尝试导入,然后提交反馈;也可以取消附带HTML。',
);
}
_pendingSubmission ??= _kind == FeedbackKind.problem
? FeedbackSubmission.problem(
id: FeedbackSubmission.newId(),
message: _message.text,
contact: _contact.text,
attachments: _attachments,
limits: widget.limits,
diagnosticLog: _includeLogs
? await DiagnosticLog.instance.export()
: null,
diagnosticHtml: html,
)
: FeedbackSubmission.development(
id: FeedbackSubmission.newId(),
school: _school.text,
contact: _contact.text,
);
setState(() {
_submitting = true;
_error = null;
});
await widget.sender.send(_pendingSubmission!);
if (_pendingSubmission?.diagnosticHtml != null) {
SchoolPageDiagnostic.instance.clear();
}
if (mounted) setState(() => _success = true);
} on FeedbackDeliveryUncertain catch (error) {
if (mounted) {
setState(() {
_uncertain = true;
_error = error.message;
});
}
} on FeedbackException catch (error) {
if (mounted) setState(() => _error = error.message);
} catch (_) {
if (mounted) setState(() => _error = '提交未完成,请检查网络后重试。填写内容和附件已保留。');
} finally {
if (mounted) setState(() => _submitting = false);
}
}
@override
Widget build(BuildContext context) => PopScope(
canPop: !_submitting,
child: Scaffold(
appBar: AppBar(title: const Text('反馈与共建')),
body: _success
? _successView(context)
: _uncertain
? _uncertainView()
: Form(
key: _form,
child: ListView(
padding: const EdgeInsets.all(22),
children: [
SegmentedButton<FeedbackKind>(
segments: const [
ButtonSegment(
value: FeedbackKind.problem,
label: Text('反馈问题和建议'),
),
ButtonSegment(
value: FeedbackKind.development,
label: Text('我要参与开发'),
),
],
selected: {_kind},
showSelectedIcon: false,
onSelectionChanged: _submitting
? null
: (value) {
setState(() {
_kind = value.single;
_pendingSubmission = null;
_error = null;
});
_form.currentState?.reset();
},
),
const SizedBox(height: 18),
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_kind == FeedbackKind.problem
? '告诉我们哪里需要改进'
: '一起让课表变得更好',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
_kind == FeedbackKind.problem
? '请描述遇到的问题或你的建议。联系方式和图片、视频都可以选填。'
: '请留下你的学校和联系方式,方便开发者与你联系。',
style: TextStyle(
fontSize: 12,
height: 1.7,
color: ink.withValues(alpha: 0.6),
),
),
const SizedBox(height: 20),
if (_kind == FeedbackKind.problem)
TextFormField(
key: const Key('feedback-message'),
controller: _message,
enabled: !_submitting,
minLines: 4,
maxLines: 8,
maxLength: 5000,
decoration: const InputDecoration(
labelText: '问题或建议(必填)',
alignLabelWithHint: true,
),
onChanged: _changed,
validator: (value) =>
(value?.trim().isEmpty ?? true)
? '请填写问题或建议'
: null,
)
else
TextFormField(
key: const Key('feedback-school'),
controller: _school,
enabled: !_submitting,
maxLength: 200,
decoration: const InputDecoration(
labelText: '学校(必填)',
),
onChanged: _changed,
validator: (value) =>
(value?.trim().isEmpty ?? true)
? '请填写学校'
: null,
),
const SizedBox(height: 14),
TextFormField(
key: const Key('feedback-contact'),
controller: _contact,
enabled: !_submitting,
maxLength: 200,
decoration: InputDecoration(
labelText: _kind == FeedbackKind.problem
? '联系方式(选填)'
: '联系方式(必填)',
hintText: '微信、QQ、邮箱等',
),
onChanged: _changed,
validator: (value) =>
_kind == FeedbackKind.development &&
(value?.trim().isEmpty ?? true)
? '请填写联系方式'
: null,
),
if (_kind == FeedbackKind.problem) ...[
CheckboxListTile(
key: const Key('feedback-logs'),
contentPadding: EdgeInsets.zero,
title: const Text('附带近10分钟诊断日志'),
subtitle: const Text(
'包含登录跳转、课表读取及错误状态,帮助排查问题;不包含密码、验证码或课表内容。',
),
value: _includeLogs,
onChanged: _submitting
? null
: (value) {
setState(() {
_includeLogs = value ?? false;
if (!_includeLogs) _includeHtml = false;
_pendingSubmission = null;
_error = null;
});
},
),
CheckboxListTile(
key: const Key('feedback-html'),
contentPadding: EdgeInsets.zero,
title: const Text('附带最近一次学校导入页面HTML'),
subtitle: const Text(
'适用于本科和研究生课表、考试导入。需先勾选日志。网页可能含姓名、学号、课程、教师和考场信息,仅保留最近10分钟,单独作为邮件附件发送。',
),
value: _includeHtml,
onChanged: _submitting || !_includeLogs
? null
: (value) async {
var accepted = value ?? false;
if (accepted) {
accepted =
await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('附带网页排查资料?'),
content: const Text(
'附件可能包含你的姓名、学号、课程及考试信息,将发送给开发者用于排查导入问题。密码、验证码、Cookie和认证参数不会包含在附件中。',
),
actions: [
TextButton(
onPressed: () =>
Navigator.pop(
context,
false,
),
child: const Text('取消'),
),
FilledButton(
onPressed: () =>
Navigator.pop(
context,
true,
),
child: const Text('同意附带'),
),
],
),
) ??
false;
}
if (!mounted) return;
setState(() {
_includeHtml = accepted;
_pendingSubmission = null;
_error = null;
});
},
),
const SizedBox(height: 10),
const Text(
'图片或视频(选填)',
style: TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 6),
Text(
'最多 ${widget.limits.maxAttachments} 个,每个 ${_mb(widget.limits.maxFileBytes)} MB,合计 ${_mb(widget.limits.maxTotalBytes)} MB',
style: TextStyle(
fontSize: 11,
color: ink.withValues(alpha: 0.55),
),
),
for (var i = 0; i < _attachments.length; i++)
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
_attachments[i].contentType.startsWith('image/')
? Icons.image_outlined
: Icons.videocam_outlined,
),
title: Text(
_attachments[i].filename,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${(_attachments[i].size / 1024).toStringAsFixed(1)} KB',
),
trailing: IconButton(
tooltip: '移除 ${_attachments[i].filename}',
onPressed: _submitting
? null
: () => setState(() {
_attachments.removeAt(i);
_pendingSubmission = null;
_error = null;
}),
icon: const Icon(Icons.close_rounded),
),
),
const SizedBox(height: 8),
OutlinedButton.icon(
key: const Key('feedback-add-media'),
onPressed: _submitting || _picking
? null
: _addAttachment,
icon: const Icon(Icons.attach_file_rounded),
label: Text(_picking ? '正在读取附件…' : '添加图片或视频'),
),
],
],
),
),
const SizedBox(height: 16),
if (_error != null) ...[
Text(
_error!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
semanticsLabel: _error,
),
const SizedBox(height: 14),
],
FilledButton(
key: const Key('feedback-submit'),
onPressed: _submitting || _picking ? null : _submit,
child: _submitting
? const Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
),
SizedBox(width: 10),
Text('正在提交…'),
],
)
: const Text('提交'),
),
const SizedBox(height: 12),
Text(
'点击提交后,仅发送你在当前类型填写的内容和选择的附件。',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
height: 1.6,
color: ink.withValues(alpha: 0.5),
),
),
],
),
),
),
);
Widget _uncertainView() => ListView(
padding: const EdgeInsets.all(28),
children: [
const Icon(Icons.mail_outline_rounded, color: river, size: 54),
const SizedBox(height: 18),
const Text(
'提交状态待确认',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
),
const SizedBox(height: 14),
Text(
_error ?? FeedbackDeliveryUncertain.instructions,
style: const TextStyle(height: 1.7),
),
const SizedBox(height: 20),
const Text('提交编号(可长按复制)'),
const SizedBox(height: 8),
SelectableText(
_pendingSubmission!.id,
key: const Key('feedback-uncertain-id'),
),
const SizedBox(height: 20),
FilledButton(
key: const Key('feedback-check-original'),
onPressed: _submitting ? null : _submit,
child: Text(_submitting ? '正在检查…' : '检查原提交状态'),
),
const SizedBox(height: 10),
const Text(
'检查会使用原提交编号和原内容。核对完成前,退出后也请勿创建相同内容的新反馈。',
style: TextStyle(fontSize: 12, height: 1.6),
),
],
);
Widget _successView(BuildContext context) => Center(
child: Padding(
padding: const EdgeInsets.all(28),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.check_circle_outline_rounded,
color: river,
size: 54,
),
const SizedBox(height: 18),
const Text(
'提交成功',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
),
const SizedBox(height: 10),
Text(
_kind == FeedbackKind.problem
? '感谢你的反馈,我们已收到。'
: '感谢你愿意参与,我们已收到你的信息。',
textAlign: TextAlign.center,
),
const SizedBox(height: 22),
FilledButton(
onPressed: () => Navigator.of(context).maybePop(),
child: const Text('完成'),
),
],
),
),
);
String _mb(int bytes) => (bytes / (1024 * 1024)).toStringAsFixed(0);
}