Add feedback service, reminder diagnostics and smooth week transitions

This commit is contained in:
Codex
2026-10-14 15:53:21 +08:00
parent c5e736b39b
commit 1f06e3741e
40 changed files with 3982 additions and 25 deletions
+13 -3
View File
@@ -1,4 +1,5 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import '../domain/app_snapshot.dart';
import '../domain/app_settings.dart';
@@ -24,6 +25,7 @@ class AppController extends ChangeNotifier {
AppSettings? _demoSettings;
Future<void> _queue = Future.value();
String? lastError, calendarSyncError;
String? reminderErrorCode;
ReminderResult reminderResult = const ReminderResult(ReminderStatus.off);
List<Course> get visibleCourses => isDemo ? _demo : snapshot.courses;
AppSettings get visibleSettings =>
@@ -331,10 +333,18 @@ class AppController extends ChangeNotifier {
settings,
clock(),
);
} catch (_) {
reminderResult = const ReminderResult(
reminderErrorCode = null;
} catch (error) {
reminderErrorCode = error is ReminderSetupException
? error.code
: error is PlatformException
? error.code
: error.runtimeType.toString();
reminderResult = ReminderResult(
ReminderStatus.error,
message: '提醒设置未完成,可重试',
message: error is ReminderSetupException
? error.message
: '提醒设置未完成,请点击“检查提醒”后重试。',
);
}
if (snapshot.calendarCleanupPending) {
+2
View File
@@ -0,0 +1,2 @@
const appVersion = '0.6.0';
const appBuildNumber = 15;
+8
View File
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import '../domain/schedule_engine.dart';
import '../features/feedback/feedback_http_sender.dart';
import '../features/feedback/feedback_submission.dart';
import '../features/timetable/course_details.dart';
import 'app_controller.dart';
@@ -20,11 +22,13 @@ class HepanApp extends StatelessWidget {
this.schoolBuilder,
this.school,
this.notificationSelection,
this.feedbackSender,
});
final AppController controller;
final Widget Function()? schoolBuilder;
final SchoolSession? school;
final ValueNotifier<String?>? notificationSelection;
final FeedbackSender? feedbackSender;
@override
Widget build(BuildContext context) => MaterialApp(
debugShowCheckedModeBanner: false,
@@ -38,6 +42,7 @@ class HepanApp extends StatelessWidget {
schoolBuilder: schoolBuilder,
school: school,
notificationSelection: notificationSelection,
feedbackSender: feedbackSender ?? configuredFeedbackSender(),
),
);
}
@@ -48,11 +53,13 @@ class _Shell extends StatefulWidget {
this.schoolBuilder,
this.school,
this.notificationSelection,
required this.feedbackSender,
});
final AppController controller;
final Widget Function()? schoolBuilder;
final SchoolSession? school;
final ValueNotifier<String?>? notificationSelection;
final FeedbackSender feedbackSender;
@override
State<_Shell> createState() => _ShellState();
}
@@ -267,6 +274,7 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
),
ProfilePage(
controller: widget.controller,
feedbackSender: widget.feedbackSender,
onLogin: _school,
onUpdateTerm: () => _school(chooseTerm: true),
authStatus: widget.school?.statusLabel ?? '未登录',
@@ -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);
@@ -6,7 +6,10 @@ import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../app/app_theme.dart';
import '../../app/app_version.dart';
import '../../data/schedule_file_import.dart';
import '../feedback/feedback_page.dart';
import '../feedback/feedback_submission.dart';
import 'settings_page.dart';
import 'import_preview.dart';
@@ -19,12 +22,14 @@ class ProfilePage extends StatelessWidget {
this.onLogout,
this.logoutLabel = '退出学校登录',
this.onUpdateTerm,
this.feedbackSender = const UnconfiguredFeedbackSender(),
});
final AppController controller;
final VoidCallback onLogin;
final VoidCallback? onLogout;
final VoidCallback? onUpdateTerm;
final String authStatus, logoutLabel;
final FeedbackSender feedbackSender;
Future<void> _import(BuildContext context) async {
try {
final file = await FilePicker.pickFile(
@@ -212,6 +217,18 @@ class ProfilePage extends StatelessWidget {
'无需登录,预览课表样式',
controller.showDemo,
),
Divider(height: 1, color: ink.withValues(alpha: 0.06)),
_tile(
Icons.chat_bubble_outline_rounded,
'反馈与共建',
'反馈问题和建议 · 我要参与开发',
() => Navigator.push(
context,
MaterialPageRoute<void>(
builder: (_) => FeedbackPage(sender: feedbackSender),
),
),
),
],
),
),
@@ -224,6 +241,14 @@ class ProfilePage extends StatelessWidget {
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
const SizedBox(height: 6),
Text(
'版本 $appVersion',
style: TextStyle(
fontSize: 11,
color: ink.withValues(alpha: 0.45),
),
),
const SizedBox(height: 6),
Text(
'今天也有值得期待的小事。',
style: TextStyle(
@@ -0,0 +1,232 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../app/app_controller.dart';
import '../../app/app_theme.dart';
import '../../app/app_version.dart' as release;
import '../../services/notification_service.dart';
class ReminderTools extends StatefulWidget {
const ReminderTools({super.key, required this.controller});
final AppController controller;
@override
State<ReminderTools> createState() => _ReminderToolsState();
}
class _ReminderToolsState extends State<ReminderTools> {
bool _busy = false;
Map<String, Object?>? _diagnosis;
String? _report;
void _notice(String text) =>
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
Future<void> _run(Future<void> Function() action) async {
if (_busy) return;
setState(() => _busy = true);
try {
await action();
} catch (error) {
if (mounted) {
_notice(
error is ReminderSetupException ? error.message : '操作未完成,请检查系统设置后重试',
);
}
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _test(bool scheduled) => _run(() async {
final message = await widget.controller.notifications.sendTestReminder(
scheduled: scheduled,
);
if (mounted) _notice(message);
});
Future<void> _check() => _run(() async {
await widget.controller.refreshServices();
final diagnosis = await widget.controller.notifications.diagnose();
if (!mounted) return;
final c = widget.controller;
final s = c.snapshot.settings;
final rawSystem = diagnosis['system'];
final system = rawSystem is Map ? rawSystem : const {};
// Copy an explicit allowlist, never raw platform errors, courses or account.
final report = <String, Object?>{
'appVersion': '${release.appVersion}+${release.appBuildNumber}',
'checkedAt': c.clock().toIso8601String(),
'remindersEnabled': s.remindersEnabled,
'semesterStart': s.semester?.startDate.toIso8601String(),
'semesterWeeks': s.semester?.totalWeeks,
'reminderOffsets': s.reminderOffsets,
'courseCount': c.snapshot.courses.length,
'scheduledCount': c.reminderResult.scheduledCount,
'reminderStatus': c.reminderResult.status.name,
for (final key in [
'notifications',
'exactAlarm',
'pendingCount',
'channelImportance',
'channelSound',
])
key: _scalar(diagnosis[key]),
'activeQueryWarning': diagnosis['activeQueryWarning'] != null,
'errorCode': _errorCode(c.reminderErrorCode),
'systemError': _errorCode(diagnosis['systemError']),
'system': {
for (final key in [
'manufacturer',
'model',
'androidVersion',
'sdk',
'backgroundRestricted',
'batteryOptimizationsIgnored',
'powerSaveMode',
'interruptionFilter',
])
key: _scalar(system[key]),
},
};
setState(() {
_diagnosis = diagnosis;
_report = const JsonEncoder.withIndent(' ').convert(report);
});
});
Object? _scalar(Object? value) =>
value is String || value is bool || value is num ? value : null;
String _errorCode(Object? value) {
if (value is String && RegExp(r'^[a-zA-Z0-9_]{1,64}$').hasMatch(value)) {
return value;
}
return value == null ? 'none' : 'unknown';
}
Future<void> _copy() => _run(() async {
final report = _report;
if (report == null) return;
await Clipboard.setData(ClipboardData(text: report));
if (mounted) _notice('提醒诊断已复制,可粘贴到意见反馈中');
});
String _permission(Object? value) => switch (value) {
true => '已允许',
false => '未允许',
_ => '暂时无法确认',
};
List<String> _summary(Map<String, Object?> diagnosis) {
final rawSystem = diagnosis['system'];
final system = rawSystem is Map ? rawSystem : const {};
final pending = diagnosis['pendingCount'];
return [
'通知权限:${_permission(diagnosis['notifications'])}',
'精确提醒:${_permission(diagnosis['exactAlarm'])}',
if (pending is int) '待发送提醒:$pending 条',
if (diagnosis['notifications'] == false) '系统通知已关闭,请在通知设置中开启。',
if (diagnosis['channelImportance'] == 0) '提醒通道已关闭,请在通知设置中开启。',
if (diagnosis['channelSound'] == false) '提醒通道处于静音,请在通知设置中检查声音。',
if (diagnosis['exactAlarm'] == false) '精确提醒未获允许,系统发送时间可能延迟。',
if (system['backgroundRestricted'] == true ||
system['powerSaveMode'] == true)
'系统正在限制后台运行,可在后台运行设置中检查。',
if (system['interruptionFilter'] is int &&
system['interruptionFilter'] != 0 &&
system['interruptionFilter'] != 1)
'勿扰模式可能影响提醒声音。',
if (pending == 0 && widget.controller.snapshot.settings.remindersEnabled)
'当前没有待发送提醒,请核对学期、课程日期和提醒提前时间。',
if (diagnosis['activeQueryWarning'] != null) '部分系统提醒状态暂时无法读取,可稍后再检查。',
if (diagnosis['systemError'] != null) '设备后台状态暂时无法读取,可在系统设置中检查。',
];
}
@override
Widget build(BuildContext context) => SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'测试与检查',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
TextButton(
onPressed: _busy ? null : () => _test(false),
child: const Text('立即测试提醒'),
),
TextButton(
onPressed: _busy ? null : () => _test(true),
child: const Text('10 秒后台测试'),
),
],
),
const Text(
'后台测试:点击后按 Home 键回到桌面,等待约 10 秒;请勿强行停止应用。',
style: TextStyle(fontSize: 12, height: 1.6, color: river),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
TextButton(
onPressed: _busy ? null : _check,
child: const Text('检查提醒'),
),
TextButton(
onPressed: _busy
? null
: () => _run(
() => widget.controller.notifications.openSettings(
background: false,
),
),
child: const Text('通知设置'),
),
TextButton(
onPressed: _busy
? null
: () => _run(
() => widget.controller.notifications.openSettings(
background: true,
),
),
child: const Text('后台运行设置'),
),
],
),
if (_busy)
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: LinearProgressIndicator(),
),
if (_diagnosis != null) ...[
const SizedBox(height: 8),
for (final line in _summary(_diagnosis!))
Padding(
padding: const EdgeInsets.only(bottom: 5),
child: Text(
line,
style: const TextStyle(fontSize: 12, height: 1.5),
),
),
TextButton(
onPressed: _busy ? null : _copy,
child: const Text('复制提醒诊断'),
),
],
],
),
);
}
@@ -6,6 +6,7 @@ import '../../app/app_theme.dart';
import '../../domain/app_settings.dart';
import '../../domain/semester.dart';
import '../../services/notification_service.dart';
import 'reminder_tools.dart';
class SettingsPage extends StatefulWidget {
const SettingsPage({
@@ -373,6 +374,8 @@ class _SettingsPageState extends State<SettingsPage> {
),
),
const SizedBox(height: 22),
if (!c.isDemo) ReminderTools(controller: c),
if (!c.isDemo) const SizedBox(height: 22),
_heading('手机日历'),
SurfaceCard(
child: Column(
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../app/app_theme.dart';
import '../../domain/schedule_engine.dart';
import '../../widgets/directional_week_transition.dart';
import '../profile/settings_page.dart';
import 'week_grid.dart';
@@ -170,14 +171,18 @@ class _TimetablePageState extends State<TimetablePage> {
),
),
if (term != null) ...[
WeekGrid(
events: ScheduleEngine.occurrences(
c.visibleCourses,
term,
).where((e) => e.week == week).toList(),
monday: monday,
today: today,
settings: c.visibleSettings,
DirectionalWeekTransition(
key: ValueKey((c.isDemo, term.firstMonday, term.totalWeeks)),
week: week,
child: WeekGrid(
events: ScheduleEngine.occurrences(
c.visibleCourses,
term,
).where((e) => e.week == week).toList(),
monday: monday,
today: today,
settings: c.visibleSettings,
),
),
const SizedBox(height: 12),
Text(
@@ -3,6 +3,7 @@ import 'package:timezone/timezone.dart' as tz;
import 'dart:convert';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter/services.dart';
import '../domain/course.dart';
import '../domain/app_settings.dart';
@@ -32,6 +33,11 @@ class ReminderRequest {
abstract class ReminderGateway {
Future<ReminderPermissions> permissions({required bool request});
Future<Set<int>> pendingIds();
Future<Set<int>> ownedIds() => pendingIds();
Future<String> sendTestReminder({required bool scheduled}) async =>
'当前环境不支持通知测试';
Future<Map<String, Object?>> diagnose() async => {};
Future<void> openSettings({required bool background}) async {}
Future<void> schedule(ReminderRequest value);
Future<void> cancel(int id);
}
@@ -116,7 +122,9 @@ class LocalNotificationService extends NotificationService {
);
}
final pending = await gateway.pendingIds();
for (final old in pending.difference(desired.keys.toSet())) {
for (final old in (await gateway.ownedIds()).difference(
desired.keys.toSet(),
)) {
await gateway.cancel(old);
_fingerprints.remove(old);
}
@@ -132,6 +140,13 @@ class LocalNotificationService extends NotificationService {
await gateway.schedule(request);
_fingerprints[entry.key] = fingerprint;
}
final saved = await gateway.pendingIds();
if (!saved.containsAll(desired.keys)) {
throw const ReminderSetupException(
'schedule_not_saved',
'系统未保存全部提醒,请点击“检查提醒”后重试。',
);
}
final message = [
if (desired.isEmpty) '当前没有待提醒的课程,请核对学期日期和上课周次。',
if (desired.isNotEmpty) '已安排 ${desired.length} 次课前提醒。',
@@ -155,17 +170,28 @@ class LocalNotificationService extends NotificationService {
@override
Future<void> cancelOwned() async {
for (final id in await gateway.pendingIds()) {
for (final id in await gateway.ownedIds()) {
await gateway.cancel(id);
}
_fingerprints.clear();
}
@override
Future<String> sendTestReminder({required bool scheduled}) =>
gateway.sendTestReminder(scheduled: scheduled);
@override
Future<Map<String, Object?>> diagnose() => gateway.diagnose();
@override
Future<void> openSettings({required bool background}) =>
gateway.openSettings(background: background);
}
class PluginReminderGateway extends ReminderGateway {
PluginReminderGateway({this.onTap});
final void Function(String key)? onTap;
final _plugin = FlutterLocalNotificationsPlugin();
static const _system = MethodChannel('com.tpschedule.app/reminders');
String? _activeQueryWarning;
Future<void>? _initializing;
Future<void> initialize() => _initializing ??= Future<void>(() async {
try {
@@ -224,15 +250,113 @@ class PluginReminderGateway extends ReminderGateway {
Future<Set<int>> pendingIds() async {
await initialize();
final pending = await _plugin.pendingNotificationRequests();
final active = await _plugin.getActiveNotifications();
return {
for (final item in pending)
if (item.payload?.startsWith('hepan:') == true) item.id,
for (final item in active)
if (item.tag == 'hepan-course' && item.id != null) item.id!,
};
}
@override
Future<Set<int>> ownedIds() async {
final owned = await pendingIds();
try {
final active = await _plugin.getActiveNotifications();
owned.addAll([
for (final item in active)
if (item.tag == 'hepan-course' && item.id != null) item.id!,
]);
_activeQueryWarning = null;
} catch (error) {
// Some OEMs deny active notification access. Future alarms must still run.
_activeQueryWarning = error is PlatformException
? error.code
: error.runtimeType.toString();
}
return owned;
}
@override
Future<String> sendTestReminder({required bool scheduled}) async {
final access = await permissions(request: false);
if (!access.notifications) return '请先允许通知和“课前提醒”通知类别。';
if (scheduled && !access.exactAlarm) return '请先允许“闹钟与提醒”,再测试后台通知。';
const details = NotificationDetails(
android: AndroidNotificationDetails(
'hepan_lessons',
'课前提醒',
channelDescription: '课程开始前的提醒',
importance: Importance.high,
priority: Priority.high,
icon: 'ic_stat_calendar',
tag: 'hepan-test',
),
);
if (scheduled) {
await _plugin.cancel(id: -1002, tag: 'hepan-test');
await _plugin.zonedSchedule(
id: -1002,
title: 'TPSchedule 后台提醒测试',
body: '后台定时提醒已送达。',
scheduledDate: tz.TZDateTime.now(tz.getLocation('Asia/Shanghai'))
.add(const Duration(seconds: 10)),
payload: 'hepan-test:scheduled',
notificationDetails: details,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
);
final saved = await _plugin.pendingNotificationRequests();
if (!saved.any((item) => item.id == -1002)) {
throw const ReminderSetupException('test_not_saved', '系统未保存测试提醒。');
}
return '已安排测试,请按手机 Home 键回到桌面,10 秒后查看通知栏。';
}
await _plugin.show(
id: -1001,
title: 'TPSchedule 通知测试',
body: '立即通知已发送,请查看通知栏。',
notificationDetails: details,
payload: 'hepan-test:immediate',
);
return '已发送测试,请查看手机通知栏。';
}
@override
Future<Map<String, Object?>> diagnose() async {
final access = await permissions(request: false);
final pending = await pendingIds();
final channels = await _android?.getNotificationChannels();
final channel = channels
?.where((item) => item.id == 'hepan_lessons')
.firstOrNull;
Map<Object?, Object?>? system;
String? systemError;
try {
system = await _system.invokeMapMethod<Object?, Object?>('getStatus');
} catch (error) {
systemError = error is PlatformException
? error.code
: error.runtimeType.toString();
}
return {
'notifications': access.notifications,
'exactAlarm': access.exactAlarm,
'pendingCount': pending.length,
'channelImportance': channel?.importance.value,
'channelSound': channel?.playSound,
'activeQueryWarning': _activeQueryWarning,
'system': system == null
? null
: {for (final e in system.entries) '${e.key}': e.value},
'systemError': ?systemError,
};
}
@override
Future<void> openSettings({required bool background}) async {
await _system.invokeMethod<void>(
background ? 'openBackgroundSettings' : 'openNotificationSettings',
);
}
@override
Future<void> cancel(int id) async {
await initialize();
@@ -22,4 +22,13 @@ abstract class NotificationService {
);
Future<void> requestPermissions();
Future<void> cancelOwned();
Future<String> sendTestReminder({required bool scheduled}) async =>
'当前环境不支持通知测试';
Future<Map<String, Object?>> diagnose() async => {};
Future<void> openSettings({required bool background}) async {}
}
class ReminderSetupException implements Exception {
const ReminderSetupException(this.code, this.message);
final String code, message;
}
@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
/// Moves the week body without adding a second horizontal scroll surface.
/// Rapid requests are coalesced to the latest week after the current movement.
class DirectionalWeekTransition extends StatefulWidget {
const DirectionalWeekTransition({
super.key,
required this.week,
required this.child,
});
final int week;
final Widget child;
@override
State<DirectionalWeekTransition> createState() =>
_DirectionalWeekTransitionState();
}
class _DirectionalWeekTransitionState extends State<DirectionalWeekTransition>
with SingleTickerProviderStateMixin {
late final AnimationController _motion = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 280),
value: 1,
)..addStatusListener(_onStatus);
late Widget _incoming;
Widget? _outgoing;
late int _incomingWeek;
double _direction = 1;
bool _reduceMotion = false;
@override
void initState() {
super.initState();
_incoming = widget.child;
_incomingWeek = widget.week;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_reduceMotion = MediaQuery.disableAnimationsOf(context);
if (_reduceMotion) _snap();
}
void _snap() {
_motion.stop();
_outgoing = null;
_incoming = widget.child;
_incomingWeek = widget.week;
_motion.value = 1;
}
void _start() {
_outgoing = _incoming;
_direction = widget.week > _incomingWeek ? 1 : -1;
_incoming = widget.child;
_incomingWeek = widget.week;
_motion.forward(from: 0);
}
@override
void didUpdateWidget(DirectionalWeekTransition oldWidget) {
super.didUpdateWidget(oldWidget);
if (_reduceMotion) {
_snap();
} else if (widget.week == _incomingWeek) {
_incoming = widget.child;
} else if (!_motion.isAnimating) {
_start();
}
// While moving, widget holds the latest request. Do not restart a tween or
// accumulate outgoing grids every time another swipe/arrow arrives.
}
void _onStatus(AnimationStatus status) {
if (status != AnimationStatus.completed ||
!mounted ||
_reduceMotion ||
_outgoing == null) {
return;
}
setState(() {
_outgoing = null;
if (widget.week != _incomingWeek) _start();
});
}
@override
void dispose() {
_motion.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => ClipRect(
child: AnimatedBuilder(
animation: _motion,
builder: (context, _) {
final progress = Curves.easeInOutCubic.transform(_motion.value);
final stale = widget.week != _incomingWeek;
return Stack(
alignment: Alignment.topLeft,
children: [
if (_outgoing != null)
ExcludeSemantics(
child: ExcludeFocus(
child: IgnorePointer(
child: FractionalTranslation(
key: const Key('week-transition-outgoing'),
translation: Offset(-_direction * 0.08 * progress, 0),
child: Opacity(opacity: 1 - progress, child: _outgoing),
),
),
),
),
ExcludeSemantics(
excluding: stale,
child: ExcludeFocus(
excluding: stale,
child: IgnorePointer(
ignoring: stale,
child: FractionalTranslation(
key: const Key('week-transition-incoming'),
translation: Offset(_direction * 0.08 * (1 - progress), 0),
child: Opacity(opacity: progress, child: _incoming),
),
),
),
),
],
);
},
),
);
}
+3 -3
View File
@@ -300,7 +300,7 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
: _blue.withValues(alpha: 0.72),
shadows: [
Shadow(
color: _blue.withValues(
color: Colors.white.withValues(
alpha: index == _position.round().clamp(0, 2)
? 0.60
: 0.38,
@@ -308,7 +308,7 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
blurRadius: 8,
),
Shadow(
color: const Color(0xFFEAF4FF).withValues(
color: Colors.white.withValues(
alpha: index == _position.round().clamp(0, 2)
? 0.95
: 0.85,
@@ -316,7 +316,7 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
blurRadius: 2.8,
),
Shadow(
color: _blue.withValues(
color: Colors.white.withValues(
alpha: index == _position.round().clamp(0, 2)
? 0.34
: 0.22,