528 lines
22 KiB
Dart
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);
|
|
}
|