Add feedback service, reminder diagnostics and smooth week transitions
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user