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

456 lines
18 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 '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;
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 {
_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,
)
: 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) ...[
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;
_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);
}