Add feedback service, reminder diagnostics and smooth week transitions
This commit is contained in:
@@ -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