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'; import '../../widgets/adaptive_page.dart'; typedef FeedbackAttachmentPicker = Future 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 createState() => _FeedbackPageState(); } class _FeedbackPageState extends State { final _form = GlobalKey(); final _message = TextEditingController(); final _contact = TextEditingController(); final _school = TextEditingController(); final _attachments = []; 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 _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 _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: AdaptivePage( maxWidth: 760, child: _success ? _successView(context) : _uncertain ? _uncertainView() : Form( key: _form, child: ListView( padding: const EdgeInsets.all(22), children: [ SegmentedButton( 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( 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); }