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
@@ -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 {