Add feedback service, reminder diagnostics and smooth week transitions

This commit is contained in:
Codex
2026-10-14 15:53:21 +08:00
parent c5e736b39b
commit 1f06e3741e
40 changed files with 3982 additions and 25 deletions
@@ -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;
}