Add feedback service, reminder diagnostics and smooth week transitions
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'feedback_submission.dart';
|
||||
|
||||
class FeedbackHttpResponse {
|
||||
const FeedbackHttpResponse(this.statusCode, this.body);
|
||||
final int statusCode;
|
||||
final List<int> body;
|
||||
}
|
||||
|
||||
abstract interface class FeedbackHttpTransport {
|
||||
Future<FeedbackHttpResponse> post({
|
||||
required Uri endpoint,
|
||||
required Map<String, String> headers,
|
||||
required Uint8List body,
|
||||
required Duration timeout,
|
||||
});
|
||||
}
|
||||
|
||||
class IoFeedbackHttpTransport implements FeedbackHttpTransport {
|
||||
const IoFeedbackHttpTransport();
|
||||
@override
|
||||
Future<FeedbackHttpResponse> post({
|
||||
required Uri endpoint,
|
||||
required Map<String, String> headers,
|
||||
required Uint8List body,
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
final client = HttpClient()..connectionTimeout = timeout;
|
||||
Future<FeedbackHttpResponse> request() async {
|
||||
final request = await client.postUrl(endpoint);
|
||||
request.followRedirects = false;
|
||||
headers.forEach(request.headers.set);
|
||||
request.contentLength = body.length;
|
||||
request.add(body);
|
||||
final response = await request.close();
|
||||
final bytes = BytesBuilder(copy: false);
|
||||
var size = 0;
|
||||
await for (final chunk in response) {
|
||||
size += chunk.length;
|
||||
if (size > HttpFeedbackSender.maxResponseBytes) {
|
||||
throw const FeedbackException('反馈服务返回异常,请稍后重试');
|
||||
}
|
||||
bytes.add(chunk);
|
||||
}
|
||||
return FeedbackHttpResponse(response.statusCode, bytes.takeBytes());
|
||||
}
|
||||
|
||||
try {
|
||||
return await request().timeout(timeout);
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class HttpFeedbackSender implements FeedbackSender {
|
||||
const HttpFeedbackSender({
|
||||
required this.endpoint,
|
||||
this.transport = const IoFeedbackHttpTransport(),
|
||||
this.timeout = const Duration(seconds: 30),
|
||||
});
|
||||
|
||||
static const maxResponseBytes = 64 * 1024;
|
||||
final String endpoint;
|
||||
final FeedbackHttpTransport transport;
|
||||
final Duration timeout;
|
||||
|
||||
@override
|
||||
Future<void> send(FeedbackSubmission submission) async {
|
||||
if (endpoint.trim().isEmpty) {
|
||||
throw const FeedbackException(UnconfiguredFeedbackSender.message);
|
||||
}
|
||||
final uri = Uri.tryParse(endpoint.trim());
|
||||
if (uri == null ||
|
||||
uri.scheme != 'https' ||
|
||||
uri.host.isEmpty ||
|
||||
uri.userInfo.isNotEmpty ||
|
||||
uri.fragment.isNotEmpty) {
|
||||
throw const FeedbackException('反馈服务配置无效,请使用安全的 HTTPS 服务地址');
|
||||
}
|
||||
FeedbackSubmission.validateAttachments(
|
||||
submission.attachments,
|
||||
FeedbackLimits.defaults,
|
||||
);
|
||||
final boundary = 'TPSchedule${FeedbackSubmission.newId()}';
|
||||
final body = _multipart(submission, boundary);
|
||||
try {
|
||||
final response = await transport
|
||||
.post(
|
||||
endpoint: uri,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data; boundary=$boundary',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: body,
|
||||
timeout: timeout,
|
||||
)
|
||||
.timeout(timeout);
|
||||
if (response.body.length > maxResponseBytes) {
|
||||
throw const FeedbackException('反馈服务返回异常,请稍后重试');
|
||||
}
|
||||
final result = _readResult(response.body);
|
||||
final matchesSubmission = result?['submission_id'] == submission.id;
|
||||
final errorCode = result?['error'];
|
||||
if (response.statusCode == 503 &&
|
||||
matchesSubmission &&
|
||||
result?['status'] == 'uncertain' &&
|
||||
(errorCode == 'delivery_uncertain' ||
|
||||
errorCode == 'storage_unavailable')) {
|
||||
throw FeedbackDeliveryUncertain(submission.id);
|
||||
}
|
||||
if (response.statusCode == 413) {
|
||||
throw const FeedbackException('附件超过服务器限制,请减少附件后重试');
|
||||
}
|
||||
if (response.statusCode == 415 && errorCode == 'unsupported_media') {
|
||||
throw const FeedbackException('服务器不支持此附件的格式或内容,请移除附件,或换用正常的图片、视频后提交');
|
||||
}
|
||||
if (response.statusCode == 422 && errorCode == 'validation_error') {
|
||||
throw const FeedbackException('填写内容未通过服务器校验,请检查必填内容、长度及附件后重新提交');
|
||||
}
|
||||
if (response.statusCode == 409 && matchesSubmission) {
|
||||
if (errorCode == 'in_progress') {
|
||||
throw const FeedbackException('原提交正在处理,请稍后重试原提交;请勿修改内容或创建新反馈');
|
||||
}
|
||||
if (errorCode == 'id_conflict') {
|
||||
throw FeedbackDeliveryUncertain(submission.id);
|
||||
}
|
||||
}
|
||||
if (response.statusCode == 429) {
|
||||
throw const FeedbackException('提交太频繁,请稍后重试');
|
||||
}
|
||||
if (response.statusCode == 503 &&
|
||||
matchesSubmission &&
|
||||
errorCode == 'smtp_unavailable' &&
|
||||
result?['status'] == 'retryable') {
|
||||
throw const FeedbackException('邮件服务暂时不可用,邮件尚未被接收;请保留内容,稍后重试原提交');
|
||||
}
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw const FeedbackException('未收到提交成功确认,请保留内容,稍后重试原提交;请勿修改后重复提交');
|
||||
}
|
||||
if (result?['success'] != true ||
|
||||
!matchesSubmission ||
|
||||
result?['status'] != 'sent') {
|
||||
throw const FeedbackException('尚未收到提交成功确认,请稍后重试');
|
||||
}
|
||||
} on FeedbackException {
|
||||
rethrow;
|
||||
} on TimeoutException {
|
||||
throw const FeedbackException('提交超时,请稍后重试。填写内容和附件已保留。');
|
||||
} on FormatException {
|
||||
throw const FeedbackException('反馈服务返回异常,请稍后重试');
|
||||
} catch (_) {
|
||||
throw const FeedbackException('无法连接反馈服务,请检查网络后重试');
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _readResult(List<int> body) {
|
||||
try {
|
||||
final result = jsonDecode(utf8.decode(body));
|
||||
return result is Map<String, dynamic> ? result : null;
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Uint8List _multipart(FeedbackSubmission submission, String boundary) {
|
||||
final bytes = BytesBuilder(copy: false);
|
||||
void write(String text) => bytes.add(utf8.encode(text));
|
||||
void field(String name, String value) {
|
||||
write(
|
||||
'--$boundary\r\nContent-Disposition: form-data; name="$name"\r\n\r\n$value\r\n',
|
||||
);
|
||||
}
|
||||
|
||||
field('submission_id', submission.id);
|
||||
field('type', submission.kind.name);
|
||||
if (submission.kind == FeedbackKind.problem) {
|
||||
field('message', submission.message!);
|
||||
} else {
|
||||
field('school', submission.school!);
|
||||
}
|
||||
if (submission.contact != null) field('contact', submission.contact!);
|
||||
for (final attachment in submission.attachments) {
|
||||
write(
|
||||
'--$boundary\r\nContent-Disposition: form-data; name="attachments"; filename="${attachment.filename}"\r\n'
|
||||
'Content-Type: ${attachment.contentType}\r\n\r\n',
|
||||
);
|
||||
bytes.add(attachment.bytes);
|
||||
write('\r\n');
|
||||
}
|
||||
write('--$boundary--\r\n');
|
||||
return bytes.takeBytes();
|
||||
}
|
||||
}
|
||||
|
||||
FeedbackSender configuredFeedbackSender() {
|
||||
const endpoint = String.fromEnvironment(
|
||||
'FEEDBACK_ENDPOINT',
|
||||
defaultValue: 'https://app.glowtps.cn/v1/feedback',
|
||||
);
|
||||
return endpoint.trim().isEmpty
|
||||
? const UnconfiguredFeedbackSender()
|
||||
: const HttpFeedbackSender(endpoint: endpoint);
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../app/app_theme.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;
|
||||
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();
|
||||
try {
|
||||
_pendingSubmission ??= _kind == FeedbackKind.problem
|
||||
? FeedbackSubmission.problem(
|
||||
id: FeedbackSubmission.newId(),
|
||||
message: _message.text,
|
||||
contact: _contact.text,
|
||||
attachments: _attachments,
|
||||
limits: widget.limits,
|
||||
)
|
||||
: FeedbackSubmission.development(
|
||||
id: FeedbackSubmission.newId(),
|
||||
school: _school.text,
|
||||
contact: _contact.text,
|
||||
);
|
||||
setState(() {
|
||||
_submitting = true;
|
||||
_error = null;
|
||||
});
|
||||
await widget.sender.send(_pendingSubmission!);
|
||||
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) ...[
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
enum FeedbackKind { problem, development }
|
||||
|
||||
class FeedbackException implements Exception {
|
||||
const FeedbackException(this.message);
|
||||
final String message;
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class FeedbackDeliveryUncertain extends FeedbackException {
|
||||
const FeedbackDeliveryUncertain(this.submissionId) : super(instructions);
|
||||
final String submissionId;
|
||||
static const instructions =
|
||||
'邮件可能已经发送,但结果尚未确认。请先联系开发者按提交编号核对收件箱;确认前请勿修改内容后重新提交或创建新反馈。';
|
||||
}
|
||||
|
||||
abstract interface class FeedbackSender {
|
||||
/// Completes only when the server confirms acceptance. Use [FeedbackSubmission.id]
|
||||
/// as the server's idempotency key so retrying a timed-out request is safe.
|
||||
Future<void> send(FeedbackSubmission submission);
|
||||
}
|
||||
|
||||
class UnconfiguredFeedbackSender implements FeedbackSender {
|
||||
const UnconfiguredFeedbackSender();
|
||||
static const message = '反馈服务暂未配置,暂时无法提交。你的填写内容已保留,请稍后再试。';
|
||||
@override
|
||||
Future<void> send(FeedbackSubmission submission) async {
|
||||
throw const FeedbackException(message);
|
||||
}
|
||||
}
|
||||
|
||||
class FeedbackLimits {
|
||||
const FeedbackLimits({
|
||||
this.maxAttachments = 3,
|
||||
this.maxFileBytes = 8 * 1024 * 1024,
|
||||
this.maxTotalBytes = 20 * 1024 * 1024,
|
||||
});
|
||||
final int maxAttachments;
|
||||
final int maxFileBytes;
|
||||
final int maxTotalBytes;
|
||||
static const defaults = FeedbackLimits();
|
||||
}
|
||||
|
||||
class FeedbackAttachment {
|
||||
FeedbackAttachment._(this.filename, this.contentType, Uint8List bytes)
|
||||
: bytes = bytes.asUnmodifiableView();
|
||||
|
||||
final String filename;
|
||||
final String contentType;
|
||||
final Uint8List bytes;
|
||||
int get size => bytes.length;
|
||||
|
||||
static const supportedTypes = {
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'png': 'image/png',
|
||||
'gif': 'image/gif',
|
||||
'webp': 'image/webp',
|
||||
'heic': 'image/heic',
|
||||
'mp4': 'video/mp4',
|
||||
'mov': 'video/quicktime',
|
||||
'm4v': 'video/x-m4v',
|
||||
'webm': 'video/webm',
|
||||
};
|
||||
|
||||
static Future<FeedbackAttachment> read({
|
||||
required String name,
|
||||
required Stream<List<int>> stream,
|
||||
int maxBytes = 8 * 1024 * 1024,
|
||||
}) async {
|
||||
final basename = name.replaceAll('\\', '/').split('/').last;
|
||||
final safeName = basename
|
||||
.replaceAll(RegExp(r'[\x00-\x1f\x7f<>:"/\\|?*]'), '')
|
||||
.trim();
|
||||
final extension = safeName.split('.').last.toLowerCase();
|
||||
final type = supportedTypes[extension];
|
||||
if (type == null || safeName.length > 150) {
|
||||
throw const FeedbackException('请选择受支持的图片或视频,文件名应少于 150 个字符');
|
||||
}
|
||||
final buffer = BytesBuilder(copy: false);
|
||||
var count = 0;
|
||||
await for (final chunk in stream) {
|
||||
count += chunk.length;
|
||||
if (count > maxBytes) {
|
||||
throw FeedbackException('单个附件不能超过 ${_megabytes(maxBytes)} MB');
|
||||
}
|
||||
buffer.add(chunk);
|
||||
}
|
||||
if (count == 0) throw const FeedbackException('不能添加空文件');
|
||||
return FeedbackAttachment._(safeName, type, buffer.takeBytes());
|
||||
}
|
||||
}
|
||||
|
||||
class FeedbackSubmission {
|
||||
FeedbackSubmission._({
|
||||
required this.id,
|
||||
required this.kind,
|
||||
this.message,
|
||||
this.contact,
|
||||
this.school,
|
||||
this.attachments = const [],
|
||||
});
|
||||
|
||||
factory FeedbackSubmission.problem({
|
||||
required String id,
|
||||
required String message,
|
||||
String? contact,
|
||||
List<FeedbackAttachment> attachments = const [],
|
||||
FeedbackLimits limits = FeedbackLimits.defaults,
|
||||
}) {
|
||||
final text = message.trim();
|
||||
if (text.isEmpty) throw const FeedbackException('请填写问题或建议');
|
||||
if (text.length > 5000) throw const FeedbackException('问题或建议不能超过 5000 个字符');
|
||||
_validateContact(contact);
|
||||
validateAttachments(attachments, limits);
|
||||
return FeedbackSubmission._(
|
||||
id: id,
|
||||
kind: FeedbackKind.problem,
|
||||
message: text,
|
||||
contact: _optional(contact),
|
||||
attachments: List.unmodifiable(attachments),
|
||||
);
|
||||
}
|
||||
|
||||
factory FeedbackSubmission.development({
|
||||
required String id,
|
||||
required String school,
|
||||
required String contact,
|
||||
}) {
|
||||
final schoolName = school.trim();
|
||||
if (schoolName.isEmpty) throw const FeedbackException('请填写学校');
|
||||
if (schoolName.length > 200) {
|
||||
throw const FeedbackException('学校名称不能超过 200 个字符');
|
||||
}
|
||||
if (contact.trim().isEmpty) throw const FeedbackException('请填写联系方式');
|
||||
_validateContact(contact);
|
||||
return FeedbackSubmission._(
|
||||
id: id,
|
||||
kind: FeedbackKind.development,
|
||||
school: schoolName,
|
||||
contact: contact.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
final String id;
|
||||
final FeedbackKind kind;
|
||||
final String? message;
|
||||
final String? contact;
|
||||
final String? school;
|
||||
final List<FeedbackAttachment> attachments;
|
||||
|
||||
static String newId() {
|
||||
final random = Random.secure();
|
||||
return List.generate(
|
||||
16,
|
||||
(_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),
|
||||
).join();
|
||||
}
|
||||
|
||||
static void validateAttachments(
|
||||
List<FeedbackAttachment> files,
|
||||
FeedbackLimits limits,
|
||||
) {
|
||||
if (files.length > limits.maxAttachments) {
|
||||
throw FeedbackException('最多添加 ${limits.maxAttachments} 个附件');
|
||||
}
|
||||
if (files.any((file) => file.size > limits.maxFileBytes)) {
|
||||
throw FeedbackException('单个附件不能超过 ${_megabytes(limits.maxFileBytes)} MB');
|
||||
}
|
||||
if (files.fold<int>(0, (total, file) => total + file.size) >
|
||||
limits.maxTotalBytes) {
|
||||
throw FeedbackException(
|
||||
'附件总大小不能超过 ${_megabytes(limits.maxTotalBytes)} MB',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static String? _optional(String? value) =>
|
||||
value == null || value.trim().isEmpty ? null : value.trim();
|
||||
static void _validateContact(String? contact) {
|
||||
if ((contact?.trim().length ?? 0) > 200) {
|
||||
throw const FeedbackException('联系方式不能超过 200 个字符');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _megabytes(int bytes) => (bytes / (1024 * 1024)).toStringAsFixed(0);
|
||||
Reference in New Issue
Block a user