feat: add elastic glass dock motion and Android tablet layouts
This commit is contained in:
@@ -5,6 +5,7 @@ import '../../app/app_theme.dart';
|
||||
import '../../services/diagnostic_log.dart';
|
||||
import '../../services/school_page_diagnostic.dart';
|
||||
import 'feedback_submission.dart';
|
||||
import '../../widgets/adaptive_page.dart';
|
||||
|
||||
typedef FeedbackAttachmentPicker = Future<FeedbackAttachment?> Function();
|
||||
|
||||
@@ -170,287 +171,292 @@ class _FeedbackPageState extends State<FeedbackPage> {
|
||||
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,
|
||||
),
|
||||
body: AdaptivePage(
|
||||
maxWidth: 760,
|
||||
child: _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('反馈问题和建议'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_kind == FeedbackKind.problem
|
||||
? '请描述遇到的问题或你的建议。联系方式和图片、视频都可以选填。'
|
||||
: '请留下你的学校和联系方式,方便开发者与你联系。',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
height: 1.7,
|
||||
color: ink.withValues(alpha: 0.6),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: FeedbackKind.development,
|
||||
label: Text('我要参与开发'),
|
||||
),
|
||||
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,
|
||||
],
|
||||
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,
|
||||
),
|
||||
onChanged: _changed,
|
||||
validator: (value) =>
|
||||
(value?.trim().isEmpty ?? true)
|
||||
? '请填写问题或建议'
|
||||
: null,
|
||||
)
|
||||
else
|
||||
),
|
||||
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-school'),
|
||||
controller: _school,
|
||||
key: const Key('feedback-contact'),
|
||||
controller: _contact,
|
||||
enabled: !_submitting,
|
||||
maxLength: 200,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '学校(必填)',
|
||||
decoration: InputDecoration(
|
||||
labelText: _kind == FeedbackKind.problem
|
||||
? '联系方式(选填)'
|
||||
: '联系方式(必填)',
|
||||
hintText: '微信、QQ、邮箱等',
|
||||
),
|
||||
onChanged: _changed,
|
||||
validator: (value) =>
|
||||
(value?.trim().isEmpty ?? true)
|
||||
? '请填写学校'
|
||||
_kind == FeedbackKind.development &&
|
||||
(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(
|
||||
if (_kind == FeedbackKind.problem) ...[
|
||||
CheckboxListTile(
|
||||
key: const Key('feedback-logs'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(
|
||||
_attachments[i].contentType.startsWith('image/')
|
||||
? Icons.image_outlined
|
||||
: Icons.videocam_outlined,
|
||||
title: const Text('附带近10分钟诊断日志'),
|
||||
subtitle: const Text(
|
||||
'包含登录跳转、课表读取及错误状态,帮助排查问题;不包含密码、验证码或课表内容。',
|
||||
),
|
||||
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);
|
||||
value: _includeLogs,
|
||||
onChanged: _submitting
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
_includeLogs = value ?? false;
|
||||
if (!_includeLogs) _includeHtml = false;
|
||||
_pendingSubmission = null;
|
||||
_error = null;
|
||||
}),
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
});
|
||||
},
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
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,
|
||||
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),
|
||||
),
|
||||
),
|
||||
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),
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user