feat: add local reminders and owned calendar synchronization
This commit is contained in:
@@ -10,6 +10,7 @@ android {
|
|||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
@@ -19,7 +20,7 @@ android {
|
|||||||
applicationId = "com.example.hepan_everyday"
|
applicationId = "com.example.hepan_everyday"
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = 24
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
|
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
|
||||||
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
|
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
|
||||||
@@ -38,6 +39,10 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||||
<application
|
<application
|
||||||
android:label="河畔Everyday"
|
android:label="河畔Everyday"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
|
<receiver android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" android:exported="false" />
|
||||||
|
<receiver android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver" android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
|
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
|
||||||
|
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package com.example.hepan_everyday
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.ContentUris
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.CalendarContract.Calendars
|
||||||
|
import android.provider.CalendarContract.Events
|
||||||
|
import io.flutter.plugin.common.BinaryMessenger
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
/** Reads only calendar names and this app's marked events. No calendar reminders are created. */
|
||||||
|
class CalendarBridge(private val activity: Activity, messenger: BinaryMessenger) {
|
||||||
|
private val channel = MethodChannel(messenger, "hepan_everyday/calendar")
|
||||||
|
private val worker = Executors.newSingleThreadExecutor()
|
||||||
|
private var permissionResult: MethodChannel.Result? = null
|
||||||
|
private val resolver get() = activity.contentResolver
|
||||||
|
private val owner get() = activity.packageName
|
||||||
|
private val code = 18482
|
||||||
|
private val prefix = "hepan://course/"
|
||||||
|
private fun allowed() = listOf(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)
|
||||||
|
.all { activity.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }
|
||||||
|
init {
|
||||||
|
channel.setMethodCallHandler { call, result ->
|
||||||
|
if (call.method == "requestAccess") {
|
||||||
|
if (allowed()) result.success(true)
|
||||||
|
else if (permissionResult != null) result.error("BUSY", "日历权限请求进行中", null)
|
||||||
|
else {
|
||||||
|
permissionResult = result
|
||||||
|
activity.requestPermissions(arrayOf(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR), code)
|
||||||
|
}
|
||||||
|
} else if (call.method !in setOf("listCalendars", "ownedEvents", "saveEvent", "deleteEvent")) {
|
||||||
|
result.notImplemented()
|
||||||
|
} else worker.execute {
|
||||||
|
try {
|
||||||
|
check(allowed()) { "日历权限未获允许" }
|
||||||
|
val args = (call.arguments as? Map<*, *>) ?: emptyMap<Any, Any>()
|
||||||
|
val value: Any? = when(call.method) {
|
||||||
|
"listCalendars" -> calendars()
|
||||||
|
"ownedEvents" -> ownedEvents(args["calendarId"] as String)
|
||||||
|
"saveEvent" -> save(args)
|
||||||
|
"deleteEvent" -> { delete(args["id"] as String, args["key"] as String); null }
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
activity.runOnUiThread { result.success(value) }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
activity.runOnUiThread { result.error("CALENDAR", "日历操作失败,请检查访问权限及所选日历", null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun onPermissionResult(requestCode: Int) {
|
||||||
|
if (requestCode == code) { permissionResult?.success(allowed()); permissionResult = null }
|
||||||
|
}
|
||||||
|
fun close() {
|
||||||
|
channel.setMethodCallHandler(null)
|
||||||
|
permissionResult?.success(false); permissionResult = null
|
||||||
|
worker.shutdown()
|
||||||
|
}
|
||||||
|
private fun calendars(): List<Map<String, String>> {
|
||||||
|
val rows = mutableListOf<Map<String, String>>()
|
||||||
|
resolver.query(Calendars.CONTENT_URI, arrayOf(Calendars._ID, Calendars.CALENDAR_DISPLAY_NAME),
|
||||||
|
"${Calendars.CALENDAR_ACCESS_LEVEL} >= ?", arrayOf(Calendars.CAL_ACCESS_CONTRIBUTOR.toString()), null)?.use {
|
||||||
|
while(it.moveToNext()) rows.add(mapOf("id" to it.getLong(0).toString(), "name" to (it.getString(1) ?: "手机日历")))
|
||||||
|
} ?: error("日历服务不可用")
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
private fun marker(key: String) = prefix + Uri.encode(key)
|
||||||
|
private fun ownedEvents(calendarId: String): List<Map<String, Any>> {
|
||||||
|
require(calendarId.toLong() > 0)
|
||||||
|
val rows = mutableListOf<Map<String, Any>>()
|
||||||
|
resolver.query(Events.CONTENT_URI, arrayOf(Events._ID, Events.CUSTOM_APP_URI),
|
||||||
|
"${Events.CALENDAR_ID}=? AND ${Events.CUSTOM_APP_PACKAGE}=? AND ${Events.DELETED}=0",
|
||||||
|
arrayOf(calendarId, owner), null)?.use {
|
||||||
|
while(it.moveToNext()) {
|
||||||
|
val uri = it.getString(1) ?: continue
|
||||||
|
if(uri.startsWith(prefix)) rows.add(mapOf("id" to it.getLong(0).toString(), "key" to Uri.decode(uri.substring(prefix.length)),
|
||||||
|
"calendarId" to calendarId, "owned" to true))
|
||||||
|
}
|
||||||
|
} ?: error("日历服务不可用")
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
private fun ownership(id: String, key: String, calendarId: String? = null): Boolean {
|
||||||
|
require(id.toLong() > 0)
|
||||||
|
resolver.query(ContentUris.withAppendedId(Events.CONTENT_URI, id.toLong()),
|
||||||
|
arrayOf(Events.CUSTOM_APP_PACKAGE, Events.CUSTOM_APP_URI, Events.CALENDAR_ID, Events.DELETED), null,null,null)?.use {
|
||||||
|
return it.moveToFirst() && it.getString(0) == owner && it.getString(1) == marker(key) && it.getInt(3)==0 &&
|
||||||
|
(calendarId == null || it.getLong(2).toString() == calendarId)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
private fun save(args: Map<*, *>): String {
|
||||||
|
val calendarId = args["calendarId"] as String
|
||||||
|
check(calendars().any { it["id"] == calendarId }) { "日历不可写入" }
|
||||||
|
val key = args["key"] as String
|
||||||
|
require(key.isNotBlank() && key.length<=512)
|
||||||
|
val start = (args["startMs"] as Number).toLong()
|
||||||
|
val end = (args["endMs"] as Number).toLong()
|
||||||
|
require(end>start)
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(Events.CALENDAR_ID, calendarId.toLong())
|
||||||
|
put(Events.TITLE, args["title"] as String)
|
||||||
|
put(Events.EVENT_LOCATION, args["room"] as String)
|
||||||
|
put(Events.DESCRIPTION, args["description"] as String)
|
||||||
|
put(Events.DTSTART, start); put(Events.DTEND, end)
|
||||||
|
put(Events.EVENT_TIMEZONE, "Asia/Shanghai")
|
||||||
|
put(Events.CUSTOM_APP_PACKAGE, owner); put(Events.CUSTOM_APP_URI, marker(key))
|
||||||
|
}
|
||||||
|
val id = args["eventId"] as? String
|
||||||
|
if (id != null && ownership(id, key, calendarId)) {
|
||||||
|
val count = resolver.update(ContentUris.withAppendedId(Events.CONTENT_URI,id.toLong()), values,
|
||||||
|
"${Events.CUSTOM_APP_PACKAGE}=? AND ${Events.CUSTOM_APP_URI}=? AND ${Events.CALENDAR_ID}=?",
|
||||||
|
arrayOf(owner, marker(key), calendarId))
|
||||||
|
check(count==1) { "日程已被修改" }
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return ContentUris.parseId(resolver.insert(Events.CONTENT_URI, values) ?: error("日程写入失败")).toString()
|
||||||
|
}
|
||||||
|
private fun delete(id: String, key: String) {
|
||||||
|
if (!ownership(id,key)) return
|
||||||
|
resolver.delete(ContentUris.withAppendedId(Events.CONTENT_URI,id.toLong()),
|
||||||
|
"${Events.CUSTOM_APP_PACKAGE}=? AND ${Events.CUSTOM_APP_URI}=?", arrayOf(owner,marker(key)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,20 @@
|
|||||||
package com.example.hepan_everyday
|
package com.example.hepan_everyday
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
|
||||||
class MainActivity : FlutterActivity()
|
class MainActivity : FlutterActivity() {
|
||||||
|
private var calendar: CalendarBridge? = null
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
calendar = CalendarBridge(this, flutterEngine.dartExecutor.binaryMessenger)
|
||||||
|
}
|
||||||
|
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||||
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||||
|
calendar?.onPermissionResult(requestCode)
|
||||||
|
}
|
||||||
|
override fun onDestroy() {
|
||||||
|
calendar?.close()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:fillColor="#FFFFFFFF" android:pathData="M7,2h2v2h6V2h2v2h2a2,2 0,0 1,2 2v14a2,2 0,0 1,-2 2H5a2,2 0,0 1,-2 -2V6a2,2 0,0 1,2 -2h2zM5,10v10h14V10zM7,12h4v4H7z"/>
|
||||||
|
</vector>
|
||||||
@@ -140,21 +140,33 @@ class AppController extends ChangeNotifier {
|
|||||||
});
|
});
|
||||||
Future<void> clearCourses() => _run(() async {
|
Future<void> clearCourses() => _run(() async {
|
||||||
// Preserve ownership mapping until cleanup has actually succeeded.
|
// Preserve ownership mapping until cleanup has actually succeeded.
|
||||||
await _save(snapshot.copyWith(courses: [], clearMetadata: true));
|
await _save(
|
||||||
|
snapshot.copyWith(
|
||||||
|
courses: [],
|
||||||
|
clearMetadata: true,
|
||||||
|
calendarCleanupPending: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
isDemo = false;
|
isDemo = false;
|
||||||
await notifications.cancelOwned();
|
try {
|
||||||
await calendar.deleteOwned(snapshot.calendarEventIds);
|
await notifications.cancelOwned();
|
||||||
await _save(snapshot.copyWith(calendarEventIds: {}));
|
} catch (_) {
|
||||||
|
reminderResult = const ReminderResult(
|
||||||
|
ReminderStatus.error,
|
||||||
|
message: '提醒清理失败,可重试',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await _cleanupCalendar();
|
||||||
});
|
});
|
||||||
Future<void> disableCalendar({required bool deleteEvents}) => _run(() async {
|
Future<void> disableCalendar({required bool deleteEvents}) => _run(() async {
|
||||||
await _save(
|
await _save(
|
||||||
snapshot.copyWith(
|
snapshot.copyWith(
|
||||||
settings: snapshot.settings.copyWith(calendarEnabled: false),
|
settings: snapshot.settings.copyWith(calendarEnabled: false),
|
||||||
|
calendarCleanupPending: deleteEvents || snapshot.calendarCleanupPending,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (deleteEvents) {
|
if (deleteEvents) {
|
||||||
await calendar.deleteOwned(snapshot.calendarEventIds);
|
await _cleanupCalendar();
|
||||||
await _save(snapshot.copyWith(calendarEventIds: {}));
|
|
||||||
}
|
}
|
||||||
calendarSyncError = null;
|
calendarSyncError = null;
|
||||||
});
|
});
|
||||||
@@ -166,6 +178,9 @@ class AppController extends ChangeNotifier {
|
|||||||
calendarSyncError = '日历权限未获允许';
|
calendarSyncError = '日历权限未获允许';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (snapshot.calendarCleanupPending) {
|
||||||
|
await _cleanupCalendar();
|
||||||
|
}
|
||||||
final calendars = await calendar.listWritableCalendars();
|
final calendars = await calendar.listWritableCalendars();
|
||||||
if (!calendars.any((c) => c.id == calendarId)) {
|
if (!calendars.any((c) => c.id == calendarId)) {
|
||||||
calendarSyncError = '请选择可写入的日历';
|
calendarSyncError = '请选择可写入的日历';
|
||||||
@@ -200,6 +215,26 @@ class AppController extends ChangeNotifier {
|
|||||||
await _reconcile();
|
await _reconcile();
|
||||||
});
|
});
|
||||||
Future<void> refreshServices() => _run(_reconcile);
|
Future<void> refreshServices() => _run(_reconcile);
|
||||||
|
Future<void> retryCalendarCleanup() => _run(() async {
|
||||||
|
if (await calendar.requestAccess()) {
|
||||||
|
await _cleanupCalendar();
|
||||||
|
} else {
|
||||||
|
calendarSyncError = '日历权限未获允许,课程日程尚未清理';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Future<void> _cleanupCalendar() async {
|
||||||
|
try {
|
||||||
|
await calendar.deleteOwned(snapshot.calendarEventIds);
|
||||||
|
await _save(
|
||||||
|
snapshot.copyWith(calendarEventIds: {}, calendarCleanupPending: false),
|
||||||
|
);
|
||||||
|
calendarSyncError = null;
|
||||||
|
} catch (_) {
|
||||||
|
calendarSyncError = '课程日程尚未清理,请允许日历访问后重试';
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _reconcile() async {
|
Future<void> _reconcile() async {
|
||||||
if (!storageReady) {
|
if (!storageReady) {
|
||||||
return;
|
return;
|
||||||
@@ -220,6 +255,13 @@ class AppController extends ChangeNotifier {
|
|||||||
message: '提醒设置未完成,可重试',
|
message: '提醒设置未完成,可重试',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (snapshot.calendarCleanupPending) {
|
||||||
|
try {
|
||||||
|
await _cleanupCalendar();
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (settings.calendarEnabled && settings.calendarId != null) {
|
if (settings.calendarEnabled && settings.calendarId != null) {
|
||||||
try {
|
try {
|
||||||
final ids = await calendar.sync(
|
final ids = await calendar.sync(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class AppSnapshot {
|
|||||||
this.settings = const AppSettings(),
|
this.settings = const AppSettings(),
|
||||||
List<Course> courses = const [],
|
List<Course> courses = const [],
|
||||||
this.importMetadata,
|
this.importMetadata,
|
||||||
|
this.calendarCleanupPending = false,
|
||||||
Map<String, String> calendarEventIds = const {},
|
Map<String, String> calendarEventIds = const {},
|
||||||
}) : courses = List.unmodifiable(courses),
|
}) : courses = List.unmodifiable(courses),
|
||||||
calendarEventIds = Map.unmodifiable(calendarEventIds);
|
calendarEventIds = Map.unmodifiable(calendarEventIds);
|
||||||
@@ -25,12 +26,14 @@ class AppSnapshot {
|
|||||||
final List<Course> courses;
|
final List<Course> courses;
|
||||||
final ImportMetadata? importMetadata;
|
final ImportMetadata? importMetadata;
|
||||||
final Map<String, String> calendarEventIds;
|
final Map<String, String> calendarEventIds;
|
||||||
|
final bool calendarCleanupPending;
|
||||||
AppSnapshot copyWith({
|
AppSnapshot copyWith({
|
||||||
AppSettings? settings,
|
AppSettings? settings,
|
||||||
List<Course>? courses,
|
List<Course>? courses,
|
||||||
ImportMetadata? importMetadata,
|
ImportMetadata? importMetadata,
|
||||||
Map<String, String>? calendarEventIds,
|
Map<String, String>? calendarEventIds,
|
||||||
bool clearMetadata = false,
|
bool clearMetadata = false,
|
||||||
|
bool? calendarCleanupPending,
|
||||||
}) => AppSnapshot(
|
}) => AppSnapshot(
|
||||||
settings: settings ?? this.settings,
|
settings: settings ?? this.settings,
|
||||||
courses: courses ?? this.courses,
|
courses: courses ?? this.courses,
|
||||||
@@ -38,6 +41,8 @@ class AppSnapshot {
|
|||||||
? null
|
? null
|
||||||
: importMetadata ?? this.importMetadata,
|
: importMetadata ?? this.importMetadata,
|
||||||
calendarEventIds: calendarEventIds ?? this.calendarEventIds,
|
calendarEventIds: calendarEventIds ?? this.calendarEventIds,
|
||||||
|
calendarCleanupPending:
|
||||||
|
calendarCleanupPending ?? this.calendarCleanupPending,
|
||||||
);
|
);
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'schemaVersion': 1,
|
'schemaVersion': 1,
|
||||||
@@ -66,6 +71,7 @@ class AppSnapshot {
|
|||||||
'parserVersion': importMetadata!.parserVersion,
|
'parserVersion': importMetadata!.parserVersion,
|
||||||
},
|
},
|
||||||
'calendarEventIds': calendarEventIds,
|
'calendarEventIds': calendarEventIds,
|
||||||
|
'calendarCleanupPending': calendarCleanupPending,
|
||||||
};
|
};
|
||||||
static AppSnapshot fromJson(Map<String, dynamic> json) {
|
static AppSnapshot fromJson(Map<String, dynamic> json) {
|
||||||
try {
|
try {
|
||||||
@@ -93,6 +99,8 @@ class AppSnapshot {
|
|||||||
);
|
);
|
||||||
final metadata = json['importMetadata'];
|
final metadata = json['importMetadata'];
|
||||||
return AppSnapshot(
|
return AppSnapshot(
|
||||||
|
calendarCleanupPending:
|
||||||
|
(json['calendarCleanupPending'] ?? false) as bool,
|
||||||
courses: values,
|
courses: values,
|
||||||
settings: AppSettings(
|
settings: AppSettings(
|
||||||
semester: term,
|
semester: term,
|
||||||
|
|||||||
@@ -275,6 +275,11 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
color: Color(0xFFB45D54),
|
color: Color(0xFFB45D54),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (c.snapshot.calendarCleanupPending)
|
||||||
|
TextButton(
|
||||||
|
onPressed: c.retryCalendarCleanup,
|
||||||
|
child: const Text('重试清理课程日程'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import '../domain/course_occurrence.dart';
|
||||||
|
import '../domain/course.dart';
|
||||||
|
import 'calendar_service.dart';
|
||||||
|
|
||||||
|
class CalendarRecord {
|
||||||
|
const CalendarRecord({
|
||||||
|
required this.id,
|
||||||
|
required this.key,
|
||||||
|
required this.calendarId,
|
||||||
|
required this.owned,
|
||||||
|
});
|
||||||
|
final String id, key, calendarId;
|
||||||
|
final bool owned;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CalendarEventDraft {
|
||||||
|
const CalendarEventDraft({
|
||||||
|
required this.key,
|
||||||
|
required this.calendarId,
|
||||||
|
required this.title,
|
||||||
|
required this.room,
|
||||||
|
required this.description,
|
||||||
|
required this.startMs,
|
||||||
|
required this.endMs,
|
||||||
|
});
|
||||||
|
final String key, calendarId, title, room, description;
|
||||||
|
final int startMs, endMs;
|
||||||
|
Map<String, Object> toMap() => {
|
||||||
|
'key': key,
|
||||||
|
'calendarId': calendarId,
|
||||||
|
'title': title,
|
||||||
|
'room': room,
|
||||||
|
'description': description,
|
||||||
|
'startMs': startMs,
|
||||||
|
'endMs': endMs,
|
||||||
|
'timeZone': 'Asia/Shanghai',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class CalendarGateway {
|
||||||
|
Future<bool> requestAccess();
|
||||||
|
Future<List<DeviceCalendar>> calendars();
|
||||||
|
Future<List<CalendarRecord>> ownedEvents(String calendarId);
|
||||||
|
Future<String> save(CalendarEventDraft event, String? eventId);
|
||||||
|
Future<void> delete(String eventId, String expectedKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AndroidCalendarService extends CalendarService {
|
||||||
|
AndroidCalendarService(this.gateway);
|
||||||
|
final CalendarGateway gateway;
|
||||||
|
@override
|
||||||
|
Future<bool> requestAccess() => gateway.requestAccess();
|
||||||
|
@override
|
||||||
|
Future<List<DeviceCalendar>> listWritableCalendars() => gateway.calendars();
|
||||||
|
@override
|
||||||
|
Future<Map<String, String>> sync(
|
||||||
|
List<CourseOccurrence> occurrences,
|
||||||
|
String calendarId,
|
||||||
|
Map<String, String> knownEventIds,
|
||||||
|
) async {
|
||||||
|
if (!(await gateway.calendars()).any((c) => c.id == calendarId)) {
|
||||||
|
throw StateError('所选日历不可写入');
|
||||||
|
}
|
||||||
|
final existing = (await gateway.ownedEvents(calendarId))
|
||||||
|
.where((e) => e.owned && e.calendarId == calendarId)
|
||||||
|
.toList();
|
||||||
|
final result = <String, String>{};
|
||||||
|
for (final occurrence in occurrences.where(
|
||||||
|
(e) => e.course.source != CourseSource.demo,
|
||||||
|
)) {
|
||||||
|
final matches = existing.where((e) => e.key == occurrence.key).toList();
|
||||||
|
matches.sort(
|
||||||
|
(a, b) => (a.id == knownEventIds[occurrence.key] ? -1 : 0).compareTo(
|
||||||
|
b.id == knownEventIds[occurrence.key] ? -1 : 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
result[occurrence.key] = await gateway.save(
|
||||||
|
CalendarEventDraft(
|
||||||
|
key: occurrence.key,
|
||||||
|
calendarId: calendarId,
|
||||||
|
title: occurrence.course.name,
|
||||||
|
room: occurrence.course.room,
|
||||||
|
description: '任课老师:${occurrence.course.teacher}\n河畔Everyday',
|
||||||
|
startMs: occurrence.start.millisecondsSinceEpoch,
|
||||||
|
endMs: occurrence.end.millisecondsSinceEpoch,
|
||||||
|
),
|
||||||
|
matches.isEmpty ? null : matches.first.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (final old in existing) {
|
||||||
|
if (result[old.key] != old.id) {
|
||||||
|
await gateway.delete(old.id, old.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteOwned(Map<String, String> eventIds) async {
|
||||||
|
for (final entry in eventIds.entries) {
|
||||||
|
await gateway.delete(entry.value, entry.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MethodCalendarGateway extends CalendarGateway {
|
||||||
|
static const channel = MethodChannel('hepan_everyday/calendar');
|
||||||
|
@override
|
||||||
|
Future<bool> requestAccess() async =>
|
||||||
|
await channel.invokeMethod<bool>('requestAccess') ?? false;
|
||||||
|
@override
|
||||||
|
Future<List<DeviceCalendar>> calendars() async {
|
||||||
|
final rows =
|
||||||
|
await channel.invokeMethod<List<dynamic>>('listCalendars') ?? [];
|
||||||
|
return rows
|
||||||
|
.map((r) => DeviceCalendar(r['id'] as String, r['name'] as String))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<CalendarRecord>> ownedEvents(String calendarId) async {
|
||||||
|
final rows =
|
||||||
|
await channel.invokeMethod<List<dynamic>>('ownedEvents', {
|
||||||
|
'calendarId': calendarId,
|
||||||
|
}) ??
|
||||||
|
[];
|
||||||
|
return rows
|
||||||
|
.map(
|
||||||
|
(r) => CalendarRecord(
|
||||||
|
id: r['id'] as String,
|
||||||
|
key: r['key'] as String,
|
||||||
|
calendarId: r['calendarId'] as String,
|
||||||
|
owned: r['owned'] as bool,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> save(CalendarEventDraft event, String? eventId) async =>
|
||||||
|
(await channel.invokeMethod<String>('saveEvent', {
|
||||||
|
...event.toMap(),
|
||||||
|
'eventId': eventId,
|
||||||
|
}))!;
|
||||||
|
@override
|
||||||
|
Future<void> delete(String eventId, String expectedKey) => channel
|
||||||
|
.invokeMethod<void>('deleteEvent', {'id': eventId, 'key': expectedKey});
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import 'package:timezone/timezone.dart' as tz;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
|
||||||
|
import '../domain/course.dart';
|
||||||
|
import '../domain/app_settings.dart';
|
||||||
|
import '../domain/course_occurrence.dart';
|
||||||
|
import 'notification_service.dart';
|
||||||
|
|
||||||
|
class ReminderPermissions {
|
||||||
|
const ReminderPermissions(this.notifications, this.exactAlarm);
|
||||||
|
final bool notifications, exactAlarm;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReminderRequest {
|
||||||
|
const ReminderRequest({
|
||||||
|
required this.id,
|
||||||
|
required this.occurrenceKey,
|
||||||
|
required this.fireAt,
|
||||||
|
required this.title,
|
||||||
|
required this.body,
|
||||||
|
required this.exact,
|
||||||
|
});
|
||||||
|
final int id;
|
||||||
|
final String occurrenceKey, title, body;
|
||||||
|
final tz.TZDateTime fireAt;
|
||||||
|
final bool exact;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class ReminderGateway {
|
||||||
|
Future<ReminderPermissions> permissions({required bool request});
|
||||||
|
Future<Set<int>> pendingIds();
|
||||||
|
Future<void> schedule(ReminderRequest value);
|
||||||
|
Future<void> cancel(int id);
|
||||||
|
}
|
||||||
|
|
||||||
|
int notificationId(String occurrenceKey) {
|
||||||
|
var hash = 0x811C9DC5;
|
||||||
|
for (final byte in utf8.encode(occurrenceKey)) {
|
||||||
|
hash = ((hash ^ byte) * 0x01000193) & 0xFFFFFFFF;
|
||||||
|
}
|
||||||
|
return hash & 0x7FFFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LocalNotificationService extends NotificationService {
|
||||||
|
LocalNotificationService(this.gateway);
|
||||||
|
final ReminderGateway gateway;
|
||||||
|
final _fingerprints = <int, String>{};
|
||||||
|
@override
|
||||||
|
Future<ReminderResult> reconcile(
|
||||||
|
List<CourseOccurrence> occurrences,
|
||||||
|
AppSettings settings,
|
||||||
|
DateTime now,
|
||||||
|
) async {
|
||||||
|
if (!settings.remindersEnabled || settings.semester == null) {
|
||||||
|
await cancelOwned();
|
||||||
|
return const ReminderResult(ReminderStatus.off);
|
||||||
|
}
|
||||||
|
final access = await gateway.permissions(request: false);
|
||||||
|
if (!access.notifications) {
|
||||||
|
await cancelOwned();
|
||||||
|
return const ReminderResult(
|
||||||
|
ReminderStatus.permissionDenied,
|
||||||
|
message: '尚未允许通知权限,请在设置中开启。',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final upcoming =
|
||||||
|
occurrences
|
||||||
|
.where(
|
||||||
|
(e) =>
|
||||||
|
e.course.source != CourseSource.demo &&
|
||||||
|
e.start
|
||||||
|
.subtract(Duration(minutes: settings.reminderMinutes))
|
||||||
|
.isAfter(now),
|
||||||
|
)
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) {
|
||||||
|
final order = a.start.compareTo(b.start);
|
||||||
|
return order == 0 ? a.key.compareTo(b.key) : order;
|
||||||
|
});
|
||||||
|
final limited = upcoming.length > 400;
|
||||||
|
final selected = upcoming.take(400).toList()
|
||||||
|
..sort((a, b) => a.key.compareTo(b.key));
|
||||||
|
final desired = <int, ReminderRequest>{};
|
||||||
|
for (final event in selected) {
|
||||||
|
var id = notificationId(event.key);
|
||||||
|
while (desired.containsKey(id)) {
|
||||||
|
id = (id + 1) & 0x7FFFFFFF;
|
||||||
|
}
|
||||||
|
desired[id] = ReminderRequest(
|
||||||
|
id: id,
|
||||||
|
occurrenceKey: event.key,
|
||||||
|
fireAt: event.start.subtract(
|
||||||
|
Duration(minutes: settings.reminderMinutes),
|
||||||
|
),
|
||||||
|
title: event.course.name,
|
||||||
|
body: '${event.course.room} · ${event.course.teacher}',
|
||||||
|
exact: access.exactAlarm,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final pending = await gateway.pendingIds();
|
||||||
|
for (final old in pending.difference(desired.keys.toSet())) {
|
||||||
|
await gateway.cancel(old);
|
||||||
|
_fingerprints.remove(old);
|
||||||
|
}
|
||||||
|
for (final entry in desired.entries) {
|
||||||
|
final request = entry.value;
|
||||||
|
final fingerprint =
|
||||||
|
'${request.fireAt.millisecondsSinceEpoch}/${request.title}/${request.body}/${request.exact}';
|
||||||
|
if (pending.contains(entry.key) &&
|
||||||
|
_fingerprints[entry.key] == fingerprint) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (pending.contains(entry.key)) await gateway.cancel(entry.key);
|
||||||
|
await gateway.schedule(request);
|
||||||
|
_fingerprints[entry.key] = fingerprint;
|
||||||
|
}
|
||||||
|
final message = [
|
||||||
|
if (!access.exactAlarm) '未允许精确提醒,系统可能延迟送达。',
|
||||||
|
if (limited) '已安排最近 400 次提醒,后续提醒在再次打开时补充。',
|
||||||
|
].join(' ');
|
||||||
|
return ReminderResult(
|
||||||
|
access.exactAlarm ? ReminderStatus.exact : ReminderStatus.inexact,
|
||||||
|
scheduledCount: desired.length,
|
||||||
|
message: message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> requestPermissions() async {
|
||||||
|
await gateway.permissions(request: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> cancelOwned() async {
|
||||||
|
for (final id in await gateway.pendingIds()) {
|
||||||
|
await gateway.cancel(id);
|
||||||
|
}
|
||||||
|
_fingerprints.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PluginReminderGateway extends ReminderGateway {
|
||||||
|
PluginReminderGateway({this.onTap});
|
||||||
|
final void Function(String key)? onTap;
|
||||||
|
final _plugin = FlutterLocalNotificationsPlugin();
|
||||||
|
Future<void>? _initializing;
|
||||||
|
Future<void> initialize() => _initializing ??= () async {
|
||||||
|
await _plugin.initialize(
|
||||||
|
settings: const InitializationSettings(
|
||||||
|
android: AndroidInitializationSettings('ic_stat_calendar'),
|
||||||
|
),
|
||||||
|
onDidReceiveNotificationResponse: (response) => _select(response.payload),
|
||||||
|
);
|
||||||
|
final launch = await _plugin.getNotificationAppLaunchDetails();
|
||||||
|
if (launch?.didNotificationLaunchApp == true) {
|
||||||
|
_select(launch?.notificationResponse?.payload);
|
||||||
|
}
|
||||||
|
}();
|
||||||
|
void _select(String? payload) {
|
||||||
|
if (payload?.startsWith('hepan:') == true) {
|
||||||
|
onTap?.call(payload!.substring(6));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AndroidFlutterLocalNotificationsPlugin? get _android => _plugin
|
||||||
|
.resolvePlatformSpecificImplementation<
|
||||||
|
AndroidFlutterLocalNotificationsPlugin
|
||||||
|
>();
|
||||||
|
@override
|
||||||
|
Future<ReminderPermissions> permissions({required bool request}) async {
|
||||||
|
await initialize();
|
||||||
|
if (request) {
|
||||||
|
await _android?.requestNotificationsPermission();
|
||||||
|
await _android?.requestExactAlarmsPermission();
|
||||||
|
}
|
||||||
|
return ReminderPermissions(
|
||||||
|
await _android?.areNotificationsEnabled() ?? false,
|
||||||
|
await _android?.canScheduleExactNotifications() ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
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<void> cancel(int id) async {
|
||||||
|
await initialize();
|
||||||
|
await _plugin.cancel(id: id, tag: 'hepan-course');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> schedule(ReminderRequest value) async {
|
||||||
|
await initialize();
|
||||||
|
await _plugin.zonedSchedule(
|
||||||
|
id: value.id,
|
||||||
|
title: value.title,
|
||||||
|
body: value.body,
|
||||||
|
scheduledDate: value.fireAt,
|
||||||
|
payload: 'hepan:${value.occurrenceKey}',
|
||||||
|
androidScheduleMode: value.exact
|
||||||
|
? AndroidScheduleMode.exactAllowWhileIdle
|
||||||
|
: AndroidScheduleMode.inexactAllowWhileIdle,
|
||||||
|
notificationDetails: const NotificationDetails(
|
||||||
|
android: AndroidNotificationDetails(
|
||||||
|
'hepan_lessons',
|
||||||
|
'课前提醒',
|
||||||
|
channelDescription: '课程开始前的提醒',
|
||||||
|
importance: Importance.high,
|
||||||
|
priority: Priority.high,
|
||||||
|
icon: 'ic_stat_calendar',
|
||||||
|
tag: 'hepan-course',
|
||||||
|
visibility: NotificationVisibility.private,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,63 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
tearDown(() => controller.dispose());
|
tearDown(() => controller.dispose());
|
||||||
|
test(
|
||||||
|
'failed calendar cleanup is retried after reopening while sync is off',
|
||||||
|
() async {
|
||||||
|
final provider = CleanupCalendar()..failDelete = true;
|
||||||
|
final first = AppController(
|
||||||
|
store,
|
||||||
|
notifications,
|
||||||
|
provider,
|
||||||
|
controller.clock,
|
||||||
|
);
|
||||||
|
await first.initialize();
|
||||||
|
await first.disableCalendar(deleteEvents: true);
|
||||||
|
expect(first.lastError, isNotNull);
|
||||||
|
first.dispose();
|
||||||
|
provider.failDelete = false;
|
||||||
|
final reopened = AppController(
|
||||||
|
store,
|
||||||
|
notifications,
|
||||||
|
provider,
|
||||||
|
controller.clock,
|
||||||
|
);
|
||||||
|
await reopened.initialize();
|
||||||
|
expect(provider.deletions, 2);
|
||||||
|
expect(reopened.snapshot.settings.calendarEnabled, isFalse);
|
||||||
|
reopened.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'notification cleanup failure does not prevent calendar cleanup',
|
||||||
|
() async {
|
||||||
|
final broken = AppController(
|
||||||
|
store,
|
||||||
|
BrokenCancellation(),
|
||||||
|
calendar,
|
||||||
|
controller.clock,
|
||||||
|
);
|
||||||
|
await broken.initialize();
|
||||||
|
await broken.clearCourses();
|
||||||
|
expect(calendar.deletions, 1);
|
||||||
|
expect(broken.snapshot.courses, isEmpty);
|
||||||
|
broken.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'denied calendar permission and unavailable calendars stay off',
|
||||||
|
() async {
|
||||||
|
await controller.initialize();
|
||||||
|
calendar.access = false;
|
||||||
|
await controller.enableCalendarFromUserAction('local');
|
||||||
|
expect(controller.snapshot.settings.calendarEnabled, isFalse);
|
||||||
|
expect(controller.calendarSyncError, isNotNull);
|
||||||
|
calendar.access = true;
|
||||||
|
await controller.enableCalendarFromUserAction('readonly-or-missing');
|
||||||
|
expect(controller.snapshot.settings.calendarEnabled, isFalse);
|
||||||
|
expect(calendar.synchronizations, 0);
|
||||||
|
},
|
||||||
|
);
|
||||||
test('a new successful demo action clears a stale save error', () async {
|
test('a new successful demo action clears a stale save error', () async {
|
||||||
await controller.initialize();
|
await controller.initialize();
|
||||||
store.failSave = true;
|
store.failSave = true;
|
||||||
@@ -121,3 +178,19 @@ class DamagedStore extends MemoryStore {
|
|||||||
@override
|
@override
|
||||||
Future<AppSnapshot?> load() async => throw const FormatException('damaged');
|
Future<AppSnapshot?> load() async => throw const FormatException('damaged');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class CleanupCalendar extends FakeCalendar {
|
||||||
|
bool failDelete = false;
|
||||||
|
@override
|
||||||
|
Future<void> deleteOwned(Map<String, String> ids) async {
|
||||||
|
deletions++;
|
||||||
|
if (failDelete) {
|
||||||
|
throw StateError('denied');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BrokenCancellation extends FakeNotifications {
|
||||||
|
@override
|
||||||
|
Future<void> cancelOwned() async => throw StateError('denied');
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:timezone/data/latest.dart' as tzdata;
|
||||||
|
import 'package:hepan_everyday/services/android_calendar_service.dart';
|
||||||
|
import 'package:hepan_everyday/services/calendar_service.dart';
|
||||||
|
import 'package:hepan_everyday/domain/schedule_engine.dart';
|
||||||
|
|
||||||
|
import '../domain/schedule_engine_test.dart' show course;
|
||||||
|
|
||||||
|
class CalendarProvider extends CalendarGateway {
|
||||||
|
bool access = true, failAfterFirst = false;
|
||||||
|
final records = <String, CalendarRecord>{};
|
||||||
|
final drafts = <String, CalendarEventDraft>{};
|
||||||
|
int inserts = 0;
|
||||||
|
@override
|
||||||
|
Future<bool> requestAccess() async => access;
|
||||||
|
@override
|
||||||
|
Future<List<DeviceCalendar>> calendars() async => [
|
||||||
|
const DeviceCalendar('local', '专用测试日历'),
|
||||||
|
];
|
||||||
|
@override
|
||||||
|
Future<List<CalendarRecord>> ownedEvents(String calendarId) async {
|
||||||
|
if (!access) throw StateError('permission denied');
|
||||||
|
return records.values.where((r) => r.calendarId == calendarId).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> save(CalendarEventDraft event, String? eventId) async {
|
||||||
|
if (failAfterFirst && inserts >= 1) {
|
||||||
|
throw StateError('provider unavailable');
|
||||||
|
}
|
||||||
|
final id = eventId ?? '${++inserts}';
|
||||||
|
records[id] = CalendarRecord(
|
||||||
|
id: id,
|
||||||
|
key: event.key,
|
||||||
|
calendarId: event.calendarId,
|
||||||
|
owned: true,
|
||||||
|
);
|
||||||
|
drafts[id] = event;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> delete(String eventId, String expectedKey) async {
|
||||||
|
final r = records[eventId];
|
||||||
|
if (r?.owned == true && r?.key == expectedKey) {
|
||||||
|
records.remove(eventId);
|
||||||
|
drafts.remove(eventId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpAll(tzdata.initializeTimeZones);
|
||||||
|
final term = Semester(startDate: DateTime(2026, 9, 9), totalWeeks: 5);
|
||||||
|
late CalendarProvider provider;
|
||||||
|
late AndroidCalendarService service;
|
||||||
|
setUp(() {
|
||||||
|
provider = CalendarProvider();
|
||||||
|
service = AndroidCalendarService(provider);
|
||||||
|
});
|
||||||
|
test(
|
||||||
|
'repeat import creates one event with UTC instants and school metadata',
|
||||||
|
() async {
|
||||||
|
final events = ScheduleEngine.occurrences([course(last: 2)], term);
|
||||||
|
final ids = await service.sync(events, 'local', {});
|
||||||
|
expect(ids, hasLength(1));
|
||||||
|
await service.sync(events, 'local', ids);
|
||||||
|
expect(provider.records, hasLength(1));
|
||||||
|
expect(provider.inserts, 1);
|
||||||
|
final event = provider.drafts.values.single;
|
||||||
|
expect(
|
||||||
|
event.startMs,
|
||||||
|
DateTime.utc(2026, 9, 9, 0, 30).millisecondsSinceEpoch,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
event.endMs,
|
||||||
|
DateTime.utc(2026, 9, 9, 2, 5).millisecondsSinceEpoch,
|
||||||
|
);
|
||||||
|
expect(event.description, contains('示例教师'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'lost mapping after an interrupted sync recovers via owned event identity',
|
||||||
|
() async {
|
||||||
|
final events = ScheduleEngine.occurrences([
|
||||||
|
course(id: 'a'),
|
||||||
|
course(id: 'b'),
|
||||||
|
], term);
|
||||||
|
provider.failAfterFirst = true;
|
||||||
|
await expectLater(service.sync(events, 'local', {}), throwsStateError);
|
||||||
|
expect(provider.records, hasLength(1));
|
||||||
|
provider.failAfterFirst = false;
|
||||||
|
await service.sync(events, 'local', {});
|
||||||
|
expect(provider.records, hasLength(2));
|
||||||
|
expect(provider.inserts, 2);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'manual deletion is repaired and unrelated events are preserved',
|
||||||
|
() async {
|
||||||
|
final events = ScheduleEngine.occurrences([course()], term);
|
||||||
|
final ids = await service.sync(events, 'local', {});
|
||||||
|
expect(ids, hasLength(1));
|
||||||
|
provider.records.clear();
|
||||||
|
provider.records['private'] = const CalendarRecord(
|
||||||
|
id: 'private',
|
||||||
|
key: 'private',
|
||||||
|
calendarId: 'local',
|
||||||
|
owned: false,
|
||||||
|
);
|
||||||
|
final updated = await service.sync(events, 'local', ids);
|
||||||
|
expect(updated, hasLength(1));
|
||||||
|
expect(provider.records, hasLength(2));
|
||||||
|
await service.deleteOwned(updated);
|
||||||
|
expect(provider.records.keys, ['private']);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'changed ownership marker cannot be deleted using an old stored mapping',
|
||||||
|
() async {
|
||||||
|
provider.records['17'] = const CalendarRecord(
|
||||||
|
id: '17',
|
||||||
|
key: 'math/2026-09-09',
|
||||||
|
calendarId: 'local',
|
||||||
|
owned: false,
|
||||||
|
);
|
||||||
|
await service.deleteOwned({'math/2026-09-09': '17'});
|
||||||
|
expect(provider.records.keys, ['17']);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test('replacing timetable removes stale owned events within selected calendar only', () async {
|
||||||
|
provider.records['other'] = const CalendarRecord(
|
||||||
|
id: 'other',
|
||||||
|
key: 'old',
|
||||||
|
calendarId: 'different',
|
||||||
|
owned: true,
|
||||||
|
);
|
||||||
|
final events = ScheduleEngine.occurrences([course()], term);
|
||||||
|
final ids = await service.sync(events, 'local', {});
|
||||||
|
expect(ids, hasLength(1));
|
||||||
|
await service.sync([], 'local', ids);
|
||||||
|
expect(provider.records.keys, ['other']);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:timezone/data/latest.dart' as tzdata;
|
||||||
|
import 'package:hepan_everyday/services/local_notification_service.dart';
|
||||||
|
import 'package:hepan_everyday/services/notification_service.dart';
|
||||||
|
import 'package:hepan_everyday/domain/schedule_engine.dart';
|
||||||
|
import 'package:hepan_everyday/domain/app_settings.dart';
|
||||||
|
|
||||||
|
import '../domain/schedule_engine_test.dart' show course;
|
||||||
|
|
||||||
|
class AlarmGateway extends ReminderGateway {
|
||||||
|
ReminderPermissions permission = const ReminderPermissions(true, true);
|
||||||
|
final alarms = <int, ReminderRequest>{};
|
||||||
|
int permissionRequests = 0;
|
||||||
|
@override
|
||||||
|
Future<ReminderPermissions> permissions({required bool request}) async {
|
||||||
|
if (request) permissionRequests++;
|
||||||
|
return permission;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Set<int>> pendingIds() async => alarms.keys.toSet();
|
||||||
|
@override
|
||||||
|
Future<void> schedule(ReminderRequest value) async {
|
||||||
|
alarms[value.id] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> cancel(int id) async {
|
||||||
|
alarms.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpAll(tzdata.initializeTimeZones);
|
||||||
|
final term = Semester(startDate: DateTime(2026, 9, 9), totalWeeks: 5);
|
||||||
|
final settings = AppSettings(semester: term);
|
||||||
|
late AlarmGateway gateway;
|
||||||
|
late LocalNotificationService service;
|
||||||
|
setUp(() {
|
||||||
|
gateway = AlarmGateway();
|
||||||
|
service = LocalNotificationService(gateway);
|
||||||
|
});
|
||||||
|
test(
|
||||||
|
'continuous course schedules one alarm fifteen minutes before school start',
|
||||||
|
() async {
|
||||||
|
final events = ScheduleEngine.occurrences([course(last: 2)], term);
|
||||||
|
final result = await service.reconcile(
|
||||||
|
events,
|
||||||
|
settings,
|
||||||
|
DateTime.utc(2026, 9, 9),
|
||||||
|
);
|
||||||
|
expect(result.scheduledCount, 1);
|
||||||
|
expect(gateway.alarms, hasLength(1));
|
||||||
|
expect(
|
||||||
|
gateway.alarms.values.single.fireAt.toUtc(),
|
||||||
|
DateTime.utc(2026, 9, 9, 0, 15),
|
||||||
|
);
|
||||||
|
expect(gateway.alarms.values.single.id, 1794407394);
|
||||||
|
expect(gateway.permissionRequests, 0);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'repeat updates replace one alarm and use changed advance time',
|
||||||
|
() async {
|
||||||
|
final events = ScheduleEngine.occurrences([course()], term);
|
||||||
|
await service.reconcile(events, settings, DateTime.utc(2026, 9, 9));
|
||||||
|
await service.reconcile(
|
||||||
|
events,
|
||||||
|
settings.copyWith(reminderMinutes: 5),
|
||||||
|
DateTime.utc(2026, 9, 9),
|
||||||
|
);
|
||||||
|
expect(gateway.alarms, hasLength(1));
|
||||||
|
expect(
|
||||||
|
gateway.alarms.values.single.fireAt.toUtc(),
|
||||||
|
DateTime.utc(2026, 9, 9, 0, 25),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'past reminder, demo and disabled settings remove pending own alarms',
|
||||||
|
() async {
|
||||||
|
final events = ScheduleEngine.occurrences([course()], term);
|
||||||
|
await service.reconcile(events, settings, DateTime.utc(2026, 9, 9));
|
||||||
|
expect(gateway.alarms, hasLength(1));
|
||||||
|
await service.reconcile(
|
||||||
|
events,
|
||||||
|
settings,
|
||||||
|
DateTime.utc(2026, 9, 9, 0, 20),
|
||||||
|
);
|
||||||
|
expect(gateway.alarms, isEmpty);
|
||||||
|
await service.reconcile(events, settings, DateTime.utc(2026, 9, 9));
|
||||||
|
expect(gateway.alarms, hasLength(1));
|
||||||
|
await service.reconcile(
|
||||||
|
events,
|
||||||
|
settings.copyWith(remindersEnabled: false),
|
||||||
|
DateTime.utc(2026, 9, 9),
|
||||||
|
);
|
||||||
|
expect(gateway.alarms, isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test('denied notifications and denied exact alarm have distinct effective status', () async {
|
||||||
|
final events = ScheduleEngine.occurrences([course()], term);
|
||||||
|
gateway.permission = const ReminderPermissions(false, false);
|
||||||
|
expect(
|
||||||
|
(await service.reconcile(
|
||||||
|
events,
|
||||||
|
settings,
|
||||||
|
DateTime.utc(2026, 9, 9),
|
||||||
|
)).status,
|
||||||
|
ReminderStatus.permissionDenied,
|
||||||
|
);
|
||||||
|
expect(gateway.alarms, isEmpty);
|
||||||
|
gateway.permission = const ReminderPermissions(true, false);
|
||||||
|
expect(
|
||||||
|
(await service.reconcile(
|
||||||
|
events,
|
||||||
|
settings,
|
||||||
|
DateTime.utc(2026, 9, 9),
|
||||||
|
)).status,
|
||||||
|
ReminderStatus.inexact,
|
||||||
|
);
|
||||||
|
expect(gateway.alarms.values.single.exact, isFalse);
|
||||||
|
});
|
||||||
|
test('permission revoked after scheduling cancels previous alarms', () async {
|
||||||
|
final events = ScheduleEngine.occurrences([course()], term);
|
||||||
|
await service.reconcile(events, settings, DateTime.utc(2026, 9, 9));
|
||||||
|
gateway.permission = const ReminderPermissions(false, false);
|
||||||
|
final result = await service.reconcile(
|
||||||
|
events,
|
||||||
|
settings,
|
||||||
|
DateTime.utc(2026, 9, 9),
|
||||||
|
);
|
||||||
|
expect(result.status, ReminderStatus.permissionDenied);
|
||||||
|
expect(gateway.alarms, isEmpty);
|
||||||
|
});
|
||||||
|
test(
|
||||||
|
'large semester keeps at most four hundred earliest reminders',
|
||||||
|
() async {
|
||||||
|
final many = ScheduleEngine.occurrences(
|
||||||
|
List.generate(501, (i) => course(id: 'class-$i')),
|
||||||
|
term,
|
||||||
|
);
|
||||||
|
final result = await service.reconcile(
|
||||||
|
many,
|
||||||
|
settings,
|
||||||
|
DateTime.utc(2026, 9, 9),
|
||||||
|
);
|
||||||
|
expect(result.scheduledCount, 400);
|
||||||
|
expect(gateway.alarms, hasLength(400));
|
||||||
|
expect(result.message, contains('后续'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user