Add feedback service, reminder diagnostics and smooth week transitions
This commit is contained in:
@@ -7,6 +7,7 @@ import io.flutter.embedding.engine.FlutterEngine
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
private var calendar: CalendarBridge? = null
|
||||
private var reminders: ReminderSystemBridge? = null
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
@@ -18,6 +19,7 @@ class MainActivity : FlutterActivity() {
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
calendar = CalendarBridge(this, flutterEngine.dartExecutor.binaryMessenger)
|
||||
reminders = ReminderSystemBridge(this, flutterEngine.dartExecutor.binaryMessenger)
|
||||
}
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
@@ -25,6 +27,7 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
override fun onDestroy() {
|
||||
calendar?.close()
|
||||
reminders?.close()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.tpschedule.app
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.ActivityManager
|
||||
import android.app.NotificationManager
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class ReminderSystemBridge(private val activity: Activity, messenger: BinaryMessenger) {
|
||||
private val channel = MethodChannel(messenger, "com.tpschedule.app/reminders")
|
||||
|
||||
init {
|
||||
channel.setMethodCallHandler { call, result ->
|
||||
try {
|
||||
when (call.method) {
|
||||
"getStatus" -> result.success(status())
|
||||
"openNotificationSettings" -> {
|
||||
openNotificationSettings()
|
||||
result.success(null)
|
||||
}
|
||||
"openBackgroundSettings" -> {
|
||||
openBackgroundSettings()
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
result.error("reminder_system_unavailable", "系统提醒设置暂时不可用", null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun status(): Map<String, Any?> {
|
||||
val activityManager = activity.getSystemService(Activity.ACTIVITY_SERVICE) as ActivityManager
|
||||
val powerManager = activity.getSystemService(Activity.POWER_SERVICE) as PowerManager
|
||||
val notificationManager = activity.getSystemService(Activity.NOTIFICATION_SERVICE) as NotificationManager
|
||||
return mapOf(
|
||||
"manufacturer" to Build.MANUFACTURER,
|
||||
"model" to Build.MODEL,
|
||||
"androidVersion" to Build.VERSION.RELEASE,
|
||||
"sdk" to Build.VERSION.SDK_INT,
|
||||
"backgroundRestricted" to if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
activityManager.isBackgroundRestricted
|
||||
} else null,
|
||||
"batteryOptimizationsIgnored" to powerManager.isIgnoringBatteryOptimizations(activity.packageName),
|
||||
"powerSaveMode" to powerManager.isPowerSaveMode,
|
||||
"interruptionFilter" to notificationManager.currentInterruptionFilter,
|
||||
)
|
||||
}
|
||||
|
||||
private fun applicationDetails() = Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.parse("package:${activity.packageName}"),
|
||||
)
|
||||
|
||||
private fun openNotificationSettings() {
|
||||
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
|
||||
.putExtra(Settings.EXTRA_APP_PACKAGE, activity.packageName)
|
||||
} else applicationDetails()
|
||||
openWithFallback(intent)
|
||||
}
|
||||
|
||||
private fun openBackgroundSettings() {
|
||||
// Show settings for the user to review; never request automatic exemption.
|
||||
openWithFallback(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
|
||||
}
|
||||
|
||||
private fun openWithFallback(intent: Intent) {
|
||||
try {
|
||||
activity.startActivity(intent)
|
||||
} catch (_: Exception) {
|
||||
activity.startActivity(applicationDetails())
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
channel.setMethodCallHandler(null)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
const appVersion = '0.6.0';
|
||||
const appBuildNumber = 15;
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: hepan_everyday
|
||||
description: TPSchedule 学生课表
|
||||
publish_to: none
|
||||
version: 0.5.6+14
|
||||
version: 0.6.0+15
|
||||
environment:
|
||||
sdk: ^3.13.5
|
||||
dependencies:
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hepan_everyday/features/feedback/feedback_http_sender.dart';
|
||||
import 'package:hepan_everyday/features/feedback/feedback_submission.dart';
|
||||
|
||||
class FakeFeedbackHttpTransport implements FeedbackHttpTransport {
|
||||
int calls = 0;
|
||||
Uri? endpoint;
|
||||
Map<String, String>? headers;
|
||||
Uint8List? body;
|
||||
FeedbackHttpResponse response = FeedbackHttpResponse(
|
||||
200,
|
||||
utf8.encode('{"success":true,"submission_id":"same-id","status":"sent"}'),
|
||||
);
|
||||
Completer<FeedbackHttpResponse>? pending;
|
||||
|
||||
@override
|
||||
Future<FeedbackHttpResponse> post({
|
||||
required Uri endpoint,
|
||||
required Map<String, String> headers,
|
||||
required Uint8List body,
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
calls++;
|
||||
this.endpoint = endpoint;
|
||||
this.headers = headers;
|
||||
this.body = body;
|
||||
return pending == null ? response : await pending!.future;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'matching uncertain delivery is distinct and never exposes server detail',
|
||||
() async {
|
||||
final transport = FakeFeedbackHttpTransport();
|
||||
final sender = HttpFeedbackSender(
|
||||
endpoint: 'https://example.com/v1/feedback',
|
||||
transport: transport,
|
||||
);
|
||||
final submission = FeedbackSubmission.problem(
|
||||
id: 'same-id',
|
||||
message: '问题',
|
||||
);
|
||||
for (final code in ['delivery_uncertain', 'storage_unavailable']) {
|
||||
transport.response = FeedbackHttpResponse(
|
||||
503,
|
||||
utf8.encode(
|
||||
jsonEncode({
|
||||
'success': false,
|
||||
'error': code,
|
||||
'detail': 'private SMTP error',
|
||||
'submission_id': 'same-id',
|
||||
'status': 'uncertain',
|
||||
}),
|
||||
),
|
||||
);
|
||||
await expectLater(
|
||||
sender.send(submission),
|
||||
throwsA(
|
||||
isA<FeedbackDeliveryUncertain>()
|
||||
.having((error) => error.submissionId, 'original ID', 'same-id')
|
||||
.having(
|
||||
(error) => error.message,
|
||||
'safe instructions',
|
||||
allOf(
|
||||
contains('请勿修改'),
|
||||
isNot(contains('private SMTP error')),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
transport.response = FeedbackHttpResponse(
|
||||
503,
|
||||
utf8.encode(
|
||||
jsonEncode({
|
||||
'success': false,
|
||||
'error': 'delivery_uncertain',
|
||||
'submission_id': 'other-id',
|
||||
'status': 'uncertain',
|
||||
}),
|
||||
),
|
||||
);
|
||||
await expectLater(
|
||||
sender.send(submission),
|
||||
throwsA(
|
||||
allOf(
|
||||
isA<FeedbackException>(),
|
||||
isNot(isA<FeedbackDeliveryUncertain>()),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'permanent media/validation failures give actionable safe messages',
|
||||
() async {
|
||||
final transport = FakeFeedbackHttpTransport();
|
||||
final sender = HttpFeedbackSender(
|
||||
endpoint: 'https://example.com/v1/feedback',
|
||||
transport: transport,
|
||||
);
|
||||
final submission = FeedbackSubmission.problem(
|
||||
id: 'same-id',
|
||||
message: '问题',
|
||||
);
|
||||
for (final rejection in [
|
||||
(status: 415, code: 'unsupported_media', expected: '移除附件'),
|
||||
(status: 422, code: 'validation_error', expected: '必填内容'),
|
||||
]) {
|
||||
transport.response = FeedbackHttpResponse(
|
||||
rejection.status,
|
||||
utf8.encode(
|
||||
jsonEncode({
|
||||
'success': false,
|
||||
'error': rejection.code,
|
||||
'detail': 'untrusted detail',
|
||||
}),
|
||||
),
|
||||
);
|
||||
await expectLater(
|
||||
sender.send(submission),
|
||||
throwsA(
|
||||
isA<FeedbackException>().having(
|
||||
(error) => error.message,
|
||||
'actionable message',
|
||||
allOf(
|
||||
contains(rejection.expected),
|
||||
isNot(contains('untrusted detail')),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
transport.response = FeedbackHttpResponse(
|
||||
503,
|
||||
utf8.encode(
|
||||
jsonEncode({
|
||||
'success': false,
|
||||
'error': 'smtp_unavailable',
|
||||
'submission_id': 'same-id',
|
||||
'status': 'retryable',
|
||||
}),
|
||||
),
|
||||
);
|
||||
await expectLater(
|
||||
sender.send(submission),
|
||||
throwsA(
|
||||
allOf(
|
||||
isA<FeedbackException>().having(
|
||||
(error) => error.message,
|
||||
'safe retry',
|
||||
contains('原提交'),
|
||||
),
|
||||
isNot(isA<FeedbackDeliveryUncertain>()),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('requires HTTPS before contacting transport', () async {
|
||||
final transport = FakeFeedbackHttpTransport();
|
||||
final submission = FeedbackSubmission.problem(id: 'same-id', message: '问题');
|
||||
for (final endpoint in [
|
||||
'',
|
||||
'http://example.com/v1/feedback',
|
||||
'https://user:pass@example.com/v1/feedback',
|
||||
]) {
|
||||
await expectLater(
|
||||
HttpFeedbackSender(
|
||||
endpoint: endpoint,
|
||||
transport: transport,
|
||||
).send(submission),
|
||||
throwsA(isA<FeedbackException>()),
|
||||
);
|
||||
}
|
||||
expect(transport.calls, 0);
|
||||
});
|
||||
|
||||
test(
|
||||
'problem multipart contains relevant fields and repeated media parts',
|
||||
() async {
|
||||
final transport = FakeFeedbackHttpTransport();
|
||||
final file = await FeedbackAttachment.read(
|
||||
name: 'x.png',
|
||||
stream: Stream.value([1, 2, 3]),
|
||||
);
|
||||
final sender = HttpFeedbackSender(
|
||||
endpoint: 'https://example.com/v1/feedback',
|
||||
transport: transport,
|
||||
);
|
||||
await sender.send(
|
||||
FeedbackSubmission.problem(
|
||||
id: 'same-id',
|
||||
message: ' 问题 ',
|
||||
attachments: [file, file],
|
||||
),
|
||||
);
|
||||
final body = utf8.decode(transport.body!, allowMalformed: true);
|
||||
expect(body, contains('name="submission_id"\r\n\r\nsame-id'));
|
||||
expect(body, contains('name="type"\r\n\r\nproblem'));
|
||||
expect(body, contains('name="message"\r\n\r\n问题'));
|
||||
expect(body, isNot(contains('name="school"')));
|
||||
expect(body, isNot(contains('name="contact"')));
|
||||
expect('name="attachments"'.allMatches(body), hasLength(2));
|
||||
expect(body, contains('Content-Type: image/png'));
|
||||
expect(
|
||||
transport.headers!['Content-Type'],
|
||||
startsWith('multipart/form-data; boundary='),
|
||||
);
|
||||
expect(transport.headers!.keys, isNot(contains('Authorization')));
|
||||
},
|
||||
);
|
||||
|
||||
test('development multipart sends school and contact without problem/media fields', () async {
|
||||
final transport = FakeFeedbackHttpTransport();
|
||||
final sender = HttpFeedbackSender(
|
||||
endpoint: 'https://example.com/v1/feedback',
|
||||
transport: transport,
|
||||
);
|
||||
await sender.send(
|
||||
FeedbackSubmission.development(
|
||||
id: 'same-id',
|
||||
school: '某大学',
|
||||
contact: 'QQ 123',
|
||||
),
|
||||
);
|
||||
final body = utf8.decode(transport.body!);
|
||||
expect(body, contains('name="type"\r\n\r\ndevelopment'));
|
||||
expect(body, contains('name="school"\r\n\r\n某大学'));
|
||||
expect(body, contains('name="contact"\r\n\r\nQQ 123'));
|
||||
expect(body, isNot(contains('name="message"')));
|
||||
expect(body, isNot(contains('name="attachments"')));
|
||||
});
|
||||
|
||||
test('only matching confirmed sent response is successful', () async {
|
||||
final transport = FakeFeedbackHttpTransport();
|
||||
final sender = HttpFeedbackSender(
|
||||
endpoint: 'https://example.com/v1/feedback',
|
||||
transport: transport,
|
||||
);
|
||||
final submission = FeedbackSubmission.problem(id: 'same-id', message: '问题');
|
||||
for (final response in [
|
||||
FeedbackHttpResponse(200, utf8.encode('{"success":true}')),
|
||||
FeedbackHttpResponse(
|
||||
200,
|
||||
utf8.encode('{"success":true,"submission_id":"other","status":"sent"}'),
|
||||
),
|
||||
FeedbackHttpResponse(
|
||||
200,
|
||||
utf8.encode(
|
||||
'{"success":true,"submission_id":"same-id","status":"queued"}',
|
||||
),
|
||||
),
|
||||
FeedbackHttpResponse(
|
||||
200,
|
||||
utf8.encode(
|
||||
'{"success":false,"submission_id":"same-id","status":"sent"}',
|
||||
),
|
||||
),
|
||||
FeedbackHttpResponse(
|
||||
500,
|
||||
utf8.encode(
|
||||
'{"success":true,"submission_id":"same-id","status":"sent"}',
|
||||
),
|
||||
),
|
||||
FeedbackHttpResponse(200, utf8.encode('bad JSON')),
|
||||
FeedbackHttpResponse(200, Uint8List(65537)),
|
||||
]) {
|
||||
transport.response = response;
|
||||
await expectLater(
|
||||
sender.send(submission),
|
||||
throwsA(isA<FeedbackException>()),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'timeout exposes safe error and repeated request keeps submission ID',
|
||||
() async {
|
||||
final transport = FakeFeedbackHttpTransport()
|
||||
..pending = Completer<FeedbackHttpResponse>();
|
||||
final sender = HttpFeedbackSender(
|
||||
endpoint: 'https://example.com/v1/feedback',
|
||||
transport: transport,
|
||||
timeout: const Duration(milliseconds: 1),
|
||||
);
|
||||
final submission = FeedbackSubmission.problem(
|
||||
id: 'same-id',
|
||||
message: '问题',
|
||||
);
|
||||
await expectLater(
|
||||
sender.send(submission),
|
||||
throwsA(isA<FeedbackException>()),
|
||||
);
|
||||
transport.pending = null;
|
||||
await sender.send(submission);
|
||||
expect(transport.calls, 2);
|
||||
expect(
|
||||
utf8.decode(transport.body!),
|
||||
contains('name="submission_id"\r\n\r\nsame-id'),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hepan_everyday/features/feedback/feedback_page.dart';
|
||||
import 'package:hepan_everyday/features/feedback/feedback_submission.dart';
|
||||
|
||||
class RecordingSender implements FeedbackSender {
|
||||
final submissions = <FeedbackSubmission>[];
|
||||
Completer<void>? pending;
|
||||
bool fail = false;
|
||||
bool uncertain = false;
|
||||
|
||||
@override
|
||||
Future<void> send(FeedbackSubmission submission) async {
|
||||
submissions.add(submission);
|
||||
if (pending != null) await pending!.future;
|
||||
if (uncertain) throw FeedbackDeliveryUncertain(submission.id);
|
||||
if (fail) throw const FeedbackException('网络暂时不可用,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reveal(
|
||||
WidgetTester tester,
|
||||
Finder target, {
|
||||
bool backwards = false,
|
||||
}) async {
|
||||
await tester.scrollUntilVisible(
|
||||
target,
|
||||
backwards ? -250 : 250,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('uncertain delivery retains original payload for status check', (
|
||||
tester,
|
||||
) async {
|
||||
final sender = RecordingSender()..uncertain = true;
|
||||
await tester.pumpWidget(MaterialApp(home: FeedbackPage(sender: sender)));
|
||||
await tester.enterText(find.byKey(const Key('feedback-message')), '原来的反馈');
|
||||
await reveal(tester, find.byKey(const Key('feedback-submit')));
|
||||
await tester.tap(find.byKey(const Key('feedback-submit')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('提交成功'), findsNothing);
|
||||
expect(find.text('提交状态待确认'), findsOneWidget);
|
||||
expect(find.byKey(const Key('feedback-message')), findsNothing);
|
||||
expect(find.byKey(const Key('feedback-submit')), findsNothing);
|
||||
final original = sender.submissions.single;
|
||||
expect(
|
||||
tester
|
||||
.widget<SelectableText>(
|
||||
find.byKey(const Key('feedback-uncertain-id')),
|
||||
)
|
||||
.data,
|
||||
original.id,
|
||||
);
|
||||
sender.uncertain = false;
|
||||
await reveal(tester, find.byKey(const Key('feedback-check-original')));
|
||||
await tester.tap(find.byKey(const Key('feedback-check-original')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sender.submissions, hasLength(2));
|
||||
expect(identical(sender.submissions[0], sender.submissions[1]), isTrue);
|
||||
expect(find.text('提交成功'), findsOneWidget);
|
||||
});
|
||||
|
||||
test('submission trims relevant fields and rejects whitespace', () {
|
||||
expect(
|
||||
() => FeedbackSubmission.problem(id: 'a', message: ' '),
|
||||
throwsA(isA<FeedbackException>()),
|
||||
);
|
||||
final problem = FeedbackSubmission.problem(
|
||||
id: 'a',
|
||||
message: ' 页面建议 ',
|
||||
contact: ' ',
|
||||
);
|
||||
expect(problem.message, '页面建议');
|
||||
expect(problem.contact, isNull);
|
||||
expect(problem.school, isNull);
|
||||
final application = FeedbackSubmission.development(
|
||||
id: 'b',
|
||||
school: ' 某大学 ',
|
||||
contact: ' 微信 abc ',
|
||||
);
|
||||
expect(application.school, '某大学');
|
||||
expect(application.contact, '微信 abc');
|
||||
expect(application.message, isNull);
|
||||
expect(application.attachments, isEmpty);
|
||||
expect(
|
||||
() => FeedbackSubmission.development(id: 'b', school: ' ', contact: 'x'),
|
||||
throwsA(isA<FeedbackException>()),
|
||||
);
|
||||
expect(
|
||||
() => FeedbackSubmission.development(id: 'b', school: 'x', contact: ' '),
|
||||
throwsA(isA<FeedbackException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('attachment read stops at byte limit and sanitizes names', () async {
|
||||
var reads = 0;
|
||||
Stream<List<int>> oversized() async* {
|
||||
reads++;
|
||||
yield [1, 2, 3];
|
||||
reads++;
|
||||
yield [4, 5, 6];
|
||||
reads++;
|
||||
yield [7];
|
||||
}
|
||||
|
||||
await expectLater(
|
||||
FeedbackAttachment.read(name: 'x.png', stream: oversized(), maxBytes: 4),
|
||||
throwsA(isA<FeedbackException>()),
|
||||
);
|
||||
expect(reads, 2);
|
||||
final attachment = await FeedbackAttachment.read(
|
||||
name: '../../unsafe\r\nname.PNG',
|
||||
stream: Stream.value([1, 2]),
|
||||
);
|
||||
expect(attachment.filename, 'unsafename.PNG');
|
||||
expect(attachment.contentType, 'image/png');
|
||||
await expectLater(
|
||||
FeedbackAttachment.read(name: 'x.exe', stream: Stream.value([1])),
|
||||
throwsA(isA<FeedbackException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('submission enforces attachment count and total limits', () async {
|
||||
final file = await FeedbackAttachment.read(
|
||||
name: 'x.jpg',
|
||||
stream: Stream.value([1]),
|
||||
);
|
||||
expect(
|
||||
() => FeedbackSubmission.problem(
|
||||
id: 'a',
|
||||
message: 'x',
|
||||
attachments: [file, file, file, file],
|
||||
),
|
||||
throwsA(isA<FeedbackException>()),
|
||||
);
|
||||
final large = await FeedbackAttachment.read(
|
||||
name: 'x.mp4',
|
||||
stream: Stream.value(Uint8List(8 * 1024 * 1024)),
|
||||
);
|
||||
expect(
|
||||
() => FeedbackSubmission.problem(
|
||||
id: 'a',
|
||||
message: 'x',
|
||||
attachments: [large, large, large],
|
||||
),
|
||||
throwsA(isA<FeedbackException>()),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('problem requires message; contact is optional', (tester) async {
|
||||
final sender = RecordingSender();
|
||||
await tester.pumpWidget(MaterialApp(home: FeedbackPage(sender: sender)));
|
||||
await reveal(tester, find.byKey(const Key('feedback-submit')));
|
||||
await tester.tap(find.byKey(const Key('feedback-submit')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('请填写问题或建议'), findsOneWidget);
|
||||
expect(sender.submissions, isEmpty);
|
||||
await reveal(
|
||||
tester,
|
||||
find.byKey(const Key('feedback-message')),
|
||||
backwards: true,
|
||||
);
|
||||
await tester.enterText(find.byKey(const Key('feedback-message')), ' 建议内容 ');
|
||||
await reveal(tester, find.byKey(const Key('feedback-submit')));
|
||||
await tester.tap(find.byKey(const Key('feedback-submit')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sender.submissions.single.message, '建议内容');
|
||||
expect(sender.submissions.single.contact, isNull);
|
||||
expect(find.text('提交成功'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('development requires school/contact and excludes hidden data', (
|
||||
tester,
|
||||
) async {
|
||||
final sender = RecordingSender();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: FeedbackPage(
|
||||
sender: sender,
|
||||
pickAttachment: () async => FeedbackAttachment.read(
|
||||
name: 'hidden.png',
|
||||
stream: Stream.value([1, 2, 3]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.enterText(find.byKey(const Key('feedback-message')), '旧问题');
|
||||
await tester.ensureVisible(find.byKey(const Key('feedback-add-media')));
|
||||
await tester.tap(find.byKey(const Key('feedback-add-media')));
|
||||
await tester.pumpAndSettle();
|
||||
await reveal(tester, find.text('我要参与开发'), backwards: true);
|
||||
await tester.tap(find.text('我要参与开发'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('feedback-message')), findsNothing);
|
||||
expect(find.byKey(const Key('feedback-add-media')), findsNothing);
|
||||
await reveal(tester, find.byKey(const Key('feedback-submit')));
|
||||
await tester.tap(find.byKey(const Key('feedback-submit')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('请填写学校'), findsOneWidget);
|
||||
expect(find.text('请填写联系方式'), findsOneWidget);
|
||||
await tester.enterText(find.byKey(const Key('feedback-school')), ' 某大学 ');
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('feedback-contact')),
|
||||
' QQ 123 ',
|
||||
);
|
||||
await reveal(tester, find.byKey(const Key('feedback-submit')));
|
||||
await tester.tap(find.byKey(const Key('feedback-submit')));
|
||||
await tester.pumpAndSettle();
|
||||
final submission = sender.submissions.single;
|
||||
expect(submission.kind, FeedbackKind.development);
|
||||
expect(submission.school, '某大学');
|
||||
expect(submission.contact, 'QQ 123');
|
||||
expect(submission.message, isNull);
|
||||
expect(submission.attachments, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'pending send prevents duplicates; failure preserves data and retry ID',
|
||||
(tester) async {
|
||||
final sender = RecordingSender()
|
||||
..pending = Completer<void>()
|
||||
..fail = true;
|
||||
await tester.pumpWidget(MaterialApp(home: FeedbackPage(sender: sender)));
|
||||
await tester.enterText(find.byKey(const Key('feedback-message')), '问题');
|
||||
await reveal(tester, find.byKey(const Key('feedback-submit')));
|
||||
await tester.tap(find.byKey(const Key('feedback-submit')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const Key('feedback-submit')));
|
||||
await tester.pump();
|
||||
expect(sender.submissions, hasLength(1));
|
||||
expect(find.text('提交成功'), findsNothing);
|
||||
sender.pending!.complete();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('网络暂时不可用,请重试'), findsOneWidget);
|
||||
expect(find.text('问题'), findsOneWidget);
|
||||
sender
|
||||
..pending = null
|
||||
..fail = false;
|
||||
await reveal(tester, find.byKey(const Key('feedback-submit')));
|
||||
await tester.tap(find.byKey(const Key('feedback-submit')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sender.submissions, hasLength(2));
|
||||
expect(sender.submissions[0].id, sender.submissions[1].id);
|
||||
expect(find.text('提交成功'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('editing a failed request starts a new submission ID', (
|
||||
tester,
|
||||
) async {
|
||||
final sender = RecordingSender()..fail = true;
|
||||
await tester.pumpWidget(MaterialApp(home: FeedbackPage(sender: sender)));
|
||||
await tester.enterText(find.byKey(const Key('feedback-message')), '原来的问题');
|
||||
await reveal(tester, find.byKey(const Key('feedback-submit')));
|
||||
await tester.tap(find.byKey(const Key('feedback-submit')));
|
||||
await tester.pumpAndSettle();
|
||||
await reveal(
|
||||
tester,
|
||||
find.byKey(const Key('feedback-message')),
|
||||
backwards: true,
|
||||
);
|
||||
await tester.enterText(find.byKey(const Key('feedback-message')), '更新的问题');
|
||||
sender.fail = false;
|
||||
await reveal(tester, find.byKey(const Key('feedback-submit')));
|
||||
await tester.tap(find.byKey(const Key('feedback-submit')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sender.submissions[1].id, isNot(sender.submissions[0].id));
|
||||
expect(sender.submissions[1].message, '更新的问题');
|
||||
});
|
||||
|
||||
testWidgets('unconfigured service gives honest error and preserves input', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: FeedbackPage()));
|
||||
await tester.enterText(find.byKey(const Key('feedback-message')), '问题');
|
||||
await reveal(tester, find.byKey(const Key('feedback-submit')));
|
||||
await tester.tap(find.byKey(const Key('feedback-submit')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text(UnconfiguredFeedbackSender.message), findsOneWidget);
|
||||
expect(find.text('提交成功'), findsNothing);
|
||||
expect(find.text('问题'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('selected media can be removed and survives send failure', (
|
||||
tester,
|
||||
) async {
|
||||
final sender = RecordingSender()..fail = true;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: FeedbackPage(
|
||||
sender: sender,
|
||||
pickAttachment: () async => FeedbackAttachment.read(
|
||||
name: 'screen.png',
|
||||
stream: Stream.value([1, 2, 3]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.enterText(find.byKey(const Key('feedback-message')), '问题');
|
||||
await tester.ensureVisible(find.byKey(const Key('feedback-add-media')));
|
||||
await tester.tap(find.byKey(const Key('feedback-add-media')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('screen.png'), findsOneWidget);
|
||||
await reveal(tester, find.byKey(const Key('feedback-submit')));
|
||||
await tester.tap(find.byKey(const Key('feedback-submit')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sender.submissions.single.attachments, hasLength(1));
|
||||
expect(find.text('screen.png'), findsOneWidget);
|
||||
await tester.ensureVisible(find.byTooltip('移除 screen.png'));
|
||||
await tester.tap(find.byTooltip('移除 screen.png'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('screen.png'), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:timezone/data/latest.dart' as tzdata;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -6,6 +7,129 @@ import 'package:hepan_everyday/services/local_notification_service.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
test(
|
||||
'querying future reminders does not depend on active notification access',
|
||||
() async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
AndroidFlutterLocalNotificationsPlugin.registerWith();
|
||||
const channel = MethodChannel(
|
||||
'dexterous.com/flutter/local_notifications',
|
||||
);
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
if (call.method == 'initialize') return true;
|
||||
if (call.method == 'getNotificationAppLaunchDetails') {
|
||||
return {'notificationLaunchedApp': false};
|
||||
}
|
||||
if (call.method == 'pendingNotificationRequests') {
|
||||
return [
|
||||
{
|
||||
'id': 123,
|
||||
'title': '课程',
|
||||
'body': '教室',
|
||||
'payload': 'hepan:course/2026-10-14',
|
||||
},
|
||||
];
|
||||
}
|
||||
if (call.method == 'getActiveNotifications') {
|
||||
throw PlatformException(code: 'active_query_unavailable');
|
||||
}
|
||||
return null;
|
||||
});
|
||||
addTearDown(() {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
expect(await PluginReminderGateway().pendingIds(), {123});
|
||||
},
|
||||
);
|
||||
test('active lookup failure preserves owned future alarms', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
AndroidFlutterLocalNotificationsPlugin.registerWith();
|
||||
const channel = MethodChannel('dexterous.com/flutter/local_notifications');
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
if (call.method == 'initialize') return true;
|
||||
if (call.method == 'getNotificationAppLaunchDetails') {
|
||||
return {'notificationLaunchedApp': false};
|
||||
}
|
||||
if (call.method == 'pendingNotificationRequests') {
|
||||
return [
|
||||
{'id': 123, 'title': '', 'body': '', 'payload': 'hepan:course'},
|
||||
{
|
||||
'id': -1002,
|
||||
'title': '',
|
||||
'body': '',
|
||||
'payload': 'hepan-test:scheduled',
|
||||
},
|
||||
];
|
||||
}
|
||||
if (call.method == 'getActiveNotifications') {
|
||||
throw PlatformException(code: 'unavailable');
|
||||
}
|
||||
return null;
|
||||
});
|
||||
addTearDown(() {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
messenger.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
expect(await PluginReminderGateway().ownedIds(), {123});
|
||||
});
|
||||
test(
|
||||
'background test uses an exact alarm isolated from course ownership',
|
||||
() async {
|
||||
tzdata.initializeTimeZones();
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
AndroidFlutterLocalNotificationsPlugin.registerWith();
|
||||
const channel = MethodChannel(
|
||||
'dexterous.com/flutter/local_notifications',
|
||||
);
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
Map<Object?, Object?>? scheduled;
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
if (call.method == 'initialize') return true;
|
||||
if (call.method == 'getNotificationAppLaunchDetails') {
|
||||
return {'notificationLaunchedApp': false};
|
||||
}
|
||||
if (call.method == 'areNotificationsEnabled' ||
|
||||
call.method == 'canScheduleExactNotifications') {
|
||||
return true;
|
||||
}
|
||||
if (call.method == 'zonedSchedule') {
|
||||
scheduled = call.arguments as Map<Object?, Object?>;
|
||||
return null;
|
||||
}
|
||||
if (call.method == 'pendingNotificationRequests') {
|
||||
return [
|
||||
if (scheduled != null)
|
||||
{
|
||||
'id': -1002,
|
||||
'title': '',
|
||||
'body': '',
|
||||
'payload': 'hepan-test:scheduled',
|
||||
},
|
||||
];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
addTearDown(() {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
messenger.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
final gateway = PluginReminderGateway();
|
||||
expect(await gateway.sendTestReminder(scheduled: true), contains('10 秒'));
|
||||
expect(scheduled?['id'], -1002);
|
||||
expect(scheduled?['payload'], 'hepan-test:scheduled');
|
||||
expect(
|
||||
(scheduled?['platformSpecifics'] as Map?)?['scheduleMode'],
|
||||
'exactAllowWhileIdle',
|
||||
);
|
||||
expect(await gateway.pendingIds(), isEmpty);
|
||||
},
|
||||
);
|
||||
test('transient native initialization failure can be retried', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
AndroidFlutterLocalNotificationsPlugin.registerWith();
|
||||
@@ -66,9 +190,9 @@ void main() {
|
||||
'playSound': true,
|
||||
'enableVibration': true,
|
||||
'enableLights': false,
|
||||
'bypassDnd': false,
|
||||
'ledColor': 0,
|
||||
'audioAttributesUsage': 5,
|
||||
'bypassDnd': false,
|
||||
'ledColor': 0,
|
||||
'audioAttributesUsage': 5,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ class AlarmGateway extends ReminderGateway {
|
||||
ReminderPermissions permission = const ReminderPermissions(true, true);
|
||||
final alarms = <int, ReminderRequest>{};
|
||||
int permissionRequests = 0;
|
||||
bool dropWrites = false;
|
||||
@override
|
||||
Future<ReminderPermissions> permissions({required bool request}) async {
|
||||
if (request) permissionRequests++;
|
||||
@@ -22,7 +23,7 @@ class AlarmGateway extends ReminderGateway {
|
||||
Future<Set<int>> pendingIds() async => alarms.keys.toSet();
|
||||
@override
|
||||
Future<void> schedule(ReminderRequest value) async {
|
||||
alarms[value.id] = value;
|
||||
if (!dropWrites) alarms[value.id] = value;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -47,6 +48,23 @@ void main() {
|
||||
return AppSnapshot.fromJson(json).settings;
|
||||
}
|
||||
|
||||
test(
|
||||
'does not report success when native scheduling did not persist',
|
||||
() async {
|
||||
gateway.dropWrites = true;
|
||||
final events = ScheduleEngine.occurrences([course()], term);
|
||||
await expectLater(
|
||||
service.reconcile(events, settings, DateTime.utc(2026, 9, 9)),
|
||||
throwsA(
|
||||
isA<ReminderSetupException>().having(
|
||||
(e) => e.code,
|
||||
'code',
|
||||
'schedule_not_saved',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'selected advance times produce independent alarms and survive refresh',
|
||||
() async {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hepan_everyday/app/app_controller.dart';
|
||||
import 'package:hepan_everyday/domain/app_snapshot.dart';
|
||||
import 'package:hepan_everyday/domain/app_settings.dart';
|
||||
import 'package:hepan_everyday/features/profile/reminder_tools.dart';
|
||||
|
||||
import '../support/fakes.dart';
|
||||
|
||||
class _Notifications extends FakeNotifications {
|
||||
Completer<Map<String, Object?>>? diagnosis;
|
||||
int? reconciliationsAtDiagnosis;
|
||||
final tests = <bool>[];
|
||||
|
||||
@override
|
||||
Future<String> sendTestReminder({required bool scheduled}) async {
|
||||
tests.add(scheduled);
|
||||
return scheduled ? '已安排测试提醒' : '已发送测试提醒';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, Object?>> diagnose() async {
|
||||
reconciliationsAtDiagnosis = reconciliations;
|
||||
return diagnosis?.future ??
|
||||
{
|
||||
'notifications': true,
|
||||
'exactAlarm': false,
|
||||
'pendingCount': 2,
|
||||
'channelImportance': 4,
|
||||
'channelSound': true,
|
||||
'system': {
|
||||
'manufacturer': 'TestPhone',
|
||||
'model': 'TestModel',
|
||||
'sdk': 35,
|
||||
'backgroundRestricted': true,
|
||||
'account': 'private@example.com',
|
||||
},
|
||||
'courseNames': ['Private course name'],
|
||||
'account': 'private@example.com',
|
||||
'errorCode': 'none',
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> openSettings({required bool background}) async {}
|
||||
}
|
||||
|
||||
Future<void> _open(WidgetTester tester, _Notifications notifications) async {
|
||||
final controller = AppController(
|
||||
MemoryStore(AppSnapshot(settings: AppSettings())),
|
||||
notifications,
|
||||
FakeCalendar(),
|
||||
() => DateTime.utc(2026, 10, 14),
|
||||
);
|
||||
await controller.initialize();
|
||||
addTearDown(controller.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: ReminderTools(controller: controller),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('test controls distinguish immediate and background reminders', (
|
||||
tester,
|
||||
) async {
|
||||
final notifications = _Notifications();
|
||||
await _open(tester, notifications);
|
||||
await tester.tap(find.text('立即测试提醒'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('已发送测试提醒'), findsOneWidget);
|
||||
await tester.tap(find.text('10 秒后台测试'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(notifications.tests, [false, true]);
|
||||
expect(find.textContaining('Home'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('diagnosis refreshes services and prevents overlapping actions', (
|
||||
tester,
|
||||
) async {
|
||||
final notifications = _Notifications()
|
||||
..diagnosis = Completer<Map<String, Object?>>();
|
||||
await _open(tester, notifications);
|
||||
final previous = notifications.reconciliations;
|
||||
await tester.tap(find.text('检查提醒'));
|
||||
await tester.pump();
|
||||
expect(notifications.reconciliationsAtDiagnosis, previous + 1);
|
||||
for (final button in tester.widgetList<TextButton>(
|
||||
find.byType(TextButton),
|
||||
)) {
|
||||
expect(button.onPressed, isNull);
|
||||
}
|
||||
notifications.diagnosis!.complete({'notifications': false});
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('系统通知已关闭'), findsOneWidget);
|
||||
expect(find.text('复制提醒诊断'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('copied diagnostics omit unapproved fields and private values', (
|
||||
tester,
|
||||
) async {
|
||||
String? copied;
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
(call) async {
|
||||
if (call.method == 'Clipboard.setData') {
|
||||
copied = (call.arguments as Map)['text'] as String;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
null,
|
||||
),
|
||||
);
|
||||
await _open(tester, _Notifications());
|
||||
await tester.tap(find.text('检查提醒'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.ensureVisible(find.text('复制提醒诊断'));
|
||||
await tester.tap(find.text('复制提醒诊断'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(copied, isNotNull);
|
||||
expect(copied, isNot(contains('private@example.com')));
|
||||
expect(copied, isNot(contains('Private course name')));
|
||||
final report = jsonDecode(copied!) as Map;
|
||||
expect(report['pendingCount'], 2);
|
||||
expect(report['courseCount'], 0);
|
||||
expect(report['appVersion'], isNotEmpty);
|
||||
expect((report['system'] as Map)['model'], 'TestModel');
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:hepan_everyday/domain/app_snapshot.dart';
|
||||
import 'package:hepan_everyday/domain/app_settings.dart';
|
||||
import 'package:hepan_everyday/domain/schedule_engine.dart';
|
||||
import 'package:hepan_everyday/features/timetable/timetable_page.dart';
|
||||
import 'package:hepan_everyday/widgets/directional_week_transition.dart';
|
||||
|
||||
import '../support/fakes.dart';
|
||||
|
||||
@@ -51,6 +52,45 @@ Future<void> _open(WidgetTester tester, AppController controller) async {
|
||||
|
||||
void main() {
|
||||
setUpAll(tzdata.initializeTimeZones);
|
||||
testWidgets(
|
||||
'week arrows animate the grid and current week reverses direction',
|
||||
(tester) async {
|
||||
await _open(tester, _controller());
|
||||
await tester.tap(find.byIcon(Icons.chevron_right_rounded));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(find.byType(DirectionalWeekTransition), findsOneWidget);
|
||||
expect(find.text('第1周课程'), findsOneWidget);
|
||||
expect(find.text('第2周课程'), findsOneWidget);
|
||||
expect(
|
||||
tester
|
||||
.widget<FractionalTranslation>(
|
||||
find.byKey(const Key('week-transition-incoming')),
|
||||
)
|
||||
.translation
|
||||
.dx,
|
||||
greaterThan(0),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('第1周课程'), findsNothing);
|
||||
await tester.tap(find.text('本周'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(
|
||||
tester
|
||||
.widget<FractionalTranslation>(
|
||||
find.byKey(const Key('week-transition-incoming')),
|
||||
)
|
||||
.translation
|
||||
.dx,
|
||||
lessThan(0),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('第 1 周'), findsOneWidget);
|
||||
expect(find.text('第2周课程'), findsNothing);
|
||||
expect(find.text('周日'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
testWidgets(
|
||||
'horizontal course swipe changes week and course taps still open',
|
||||
(tester) async {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hepan_everyday/widgets/directional_week_transition.dart';
|
||||
|
||||
Widget _week(int week, {bool reduceMotion = false, VoidCallback? onTap}) =>
|
||||
MaterialApp(
|
||||
home: MediaQuery(
|
||||
data: MediaQueryData(disableAnimations: reduceMotion),
|
||||
child: Scaffold(
|
||||
body: DirectionalWeekTransition(
|
||||
week: week,
|
||||
child: SizedBox(
|
||||
width: 320,
|
||||
height: 180,
|
||||
child: Center(
|
||||
child: Semantics(
|
||||
label: 'week-$week-course',
|
||||
button: true,
|
||||
excludeSemantics: true,
|
||||
child: TextButton(
|
||||
onPressed: onTap ?? () {},
|
||||
child: Text('Week $week'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
FractionalTranslation _translation(WidgetTester tester, String layer) =>
|
||||
tester.widget(find.byKey(Key('week-transition-$layer')));
|
||||
|
||||
Finder get _layers => find.descendant(
|
||||
of: find.byType(DirectionalWeekTransition),
|
||||
matching: find.byType(FractionalTranslation),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'next and previous weeks move in opposite directions then settle',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(_week(1));
|
||||
await tester.pumpWidget(_week(2));
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(find.text('Week 1'), findsOneWidget);
|
||||
expect(find.text('Week 2'), findsOneWidget);
|
||||
expect(_translation(tester, 'incoming').translation.dx, greaterThan(0));
|
||||
expect(_translation(tester, 'outgoing').translation.dx, lessThan(0));
|
||||
final fade = tester.widget<Opacity>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('week-transition-incoming')),
|
||||
matching: find.byType(Opacity),
|
||||
),
|
||||
);
|
||||
expect(fade.opacity, greaterThan(0));
|
||||
expect(fade.opacity, lessThan(1));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Week 1'), findsNothing);
|
||||
expect(_translation(tester, 'incoming').translation, Offset.zero);
|
||||
|
||||
await tester.pumpWidget(_week(1));
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(_translation(tester, 'incoming').translation.dx, lessThan(0));
|
||||
expect(_translation(tester, 'outgoing').translation.dx, greaterThan(0));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Week 2'), findsNothing);
|
||||
expect(_translation(tester, 'incoming').translation, Offset.zero);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('outgoing courses cannot receive taps or duplicate semantics', (
|
||||
tester,
|
||||
) async {
|
||||
final semantics = tester.ensureSemantics();
|
||||
try {
|
||||
var oldTaps = 0;
|
||||
await tester.pumpWidget(_week(1, onTap: () => oldTaps++));
|
||||
await tester.pumpWidget(_week(2));
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(find.bySemanticsLabel('week-1-course'), findsNothing);
|
||||
expect(find.bySemanticsLabel('week-2-course'), findsOneWidget);
|
||||
final oldPointer = tester.widget<IgnorePointer>(
|
||||
find
|
||||
.ancestor(
|
||||
of: find.text('Week 1'),
|
||||
matching: find.byType(IgnorePointer),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
expect(oldPointer.ignoring, isTrue);
|
||||
await tester.tap(find.text('Week 1'), warnIfMissed: false);
|
||||
expect(oldTaps, 0);
|
||||
await tester.pumpAndSettle();
|
||||
} finally {
|
||||
semantics.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'rapid requests keep the running tween and coalesce latest week',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(_week(1));
|
||||
await tester.pumpWidget(_week(2));
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
final before = _translation(tester, 'incoming').translation;
|
||||
await tester.pumpWidget(_week(3));
|
||||
expect(_translation(tester, 'incoming').translation, before);
|
||||
await tester.pumpWidget(_week(4));
|
||||
expect(_translation(tester, 'incoming').translation, before);
|
||||
expect(_layers, findsNWidgets(2));
|
||||
expect(find.text('Week 3'), findsNothing);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Week 4'), findsOneWidget);
|
||||
expect(find.text('Week 1'), findsNothing);
|
||||
expect(find.text('Week 2'), findsNothing);
|
||||
expect(_layers, findsOneWidget);
|
||||
expect(_translation(tester, 'incoming').translation, Offset.zero);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('rapid reversal returns to latest week without stale layers', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_week(1));
|
||||
await tester.pumpWidget(_week(2));
|
||||
await tester.pump(const Duration(milliseconds: 70));
|
||||
await tester.pumpWidget(_week(3));
|
||||
await tester.pumpWidget(_week(1));
|
||||
await tester.pump(const Duration(milliseconds: 210));
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(_translation(tester, 'incoming').translation.dx, lessThan(0));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Week 1'), findsOneWidget);
|
||||
expect(find.text('Week 2'), findsNothing);
|
||||
expect(find.text('Week 3'), findsNothing);
|
||||
expect(_layers, findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('reduced motion snaps and clears an in-flight transition', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_week(1));
|
||||
await tester.pumpWidget(_week(2));
|
||||
await tester.pump(const Duration(milliseconds: 70));
|
||||
await tester.pumpWidget(_week(3, reduceMotion: true));
|
||||
expect(find.text('Week 1'), findsNothing);
|
||||
expect(find.text('Week 2'), findsNothing);
|
||||
expect(find.text('Week 3'), findsOneWidget);
|
||||
expect(_translation(tester, 'incoming').translation, Offset.zero);
|
||||
expect(find.byKey(const Key('week-transition-outgoing')), findsNothing);
|
||||
await tester.pumpWidget(_week(1, reduceMotion: true));
|
||||
expect(find.text('Week 1'), findsOneWidget);
|
||||
expect(find.text('Week 3'), findsNothing);
|
||||
expect(tester.hasRunningAnimations, isFalse);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user