forked from eva/focus-flow
feat: split scheduler into workspace packages
This commit is contained in:
parent
44c3ca4c93
commit
60ca067236
120 changed files with 12543 additions and 48 deletions
1
packages/scheduler_backup/analysis_options.yaml
Normal file
1
packages/scheduler_backup/analysis_options.yaml
Normal file
|
|
@ -0,0 +1 @@
|
|||
include: package:lints/recommended.yaml
|
||||
31
packages/scheduler_backup/bin/backup.dart
Normal file
31
packages/scheduler_backup/bin/backup.dart
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:scheduler_backup/backup.dart';
|
||||
|
||||
Future<void> main(List<String> arguments) async {
|
||||
final passphrase = _option(arguments, '--passphrase') ??
|
||||
Platform.environment['SCHEDULER_BACKUP_PASSPHRASE'];
|
||||
if (passphrase == null || passphrase.isEmpty) {
|
||||
stderr.writeln(
|
||||
'Usage: dart run scheduler_backup:backup --passphrase <passphrase>',
|
||||
);
|
||||
exitCode = 64;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final backup = await createEncryptedBackup(passphrase: passphrase);
|
||||
stdout.writeln(backup.path);
|
||||
} on Object catch (error) {
|
||||
stderr.writeln(error);
|
||||
exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
String? _option(List<String> arguments, String name) {
|
||||
final index = arguments.indexOf(name);
|
||||
if (index == -1 || index + 1 >= arguments.length) {
|
||||
return null;
|
||||
}
|
||||
return arguments[index + 1];
|
||||
}
|
||||
4
packages/scheduler_backup/lib/backup.dart
Normal file
4
packages/scheduler_backup/lib/backup.dart
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/// Encrypted SQLite backup helpers.
|
||||
library;
|
||||
|
||||
export 'src/encrypted_backup.dart';
|
||||
4
packages/scheduler_backup/lib/scheduler_backup.dart
Normal file
4
packages/scheduler_backup/lib/scheduler_backup.dart
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/// Package-name entry point for scheduler backup helpers.
|
||||
library;
|
||||
|
||||
export 'backup.dart';
|
||||
262
packages/scheduler_backup/lib/src/encrypted_backup.dart
Normal file
262
packages/scheduler_backup/lib/src/encrypted_backup.dart
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
|
||||
/// Default scheduler data directory name under the user's home directory.
|
||||
const defaultSchedulerDirectoryName = 'ADHD_Scheduler';
|
||||
|
||||
/// Default SQLite database file name used by local scheduler persistence.
|
||||
const defaultSchedulerDatabaseFileName = 'scheduler.sqlite';
|
||||
|
||||
/// Default encrypted backup directory name under the scheduler data directory.
|
||||
const defaultSchedulerBackupDirectoryName = 'backups';
|
||||
|
||||
const _formatMagic = 'ADHD_SCHEDULER_BACKUP_V1';
|
||||
const _fileExtension = '.db.enc';
|
||||
const _kdfIterations = 200000;
|
||||
const _saltLength = 16;
|
||||
const _nonceLength = 12;
|
||||
const _keyLength = 32;
|
||||
|
||||
final _utf8 = utf8.encoder;
|
||||
|
||||
/// Thrown when an encrypted backup cannot be decrypted with the passphrase.
|
||||
final class BackupDecryptionException implements Exception {
|
||||
/// Creates a decryption failure with optional diagnostic [message].
|
||||
const BackupDecryptionException([
|
||||
this.message = 'Backup could not be decrypted.',
|
||||
]);
|
||||
|
||||
/// Diagnostic text for logs and tests.
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => 'BackupDecryptionException: $message';
|
||||
}
|
||||
|
||||
/// Thrown when backup input paths or file contents are invalid.
|
||||
final class BackupException implements Exception {
|
||||
/// Creates a backup exception with diagnostic [message].
|
||||
const BackupException(this.message);
|
||||
|
||||
/// Diagnostic text for logs and tests.
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => 'BackupException: $message';
|
||||
}
|
||||
|
||||
/// Create an encrypted backup of the scheduler SQLite file.
|
||||
///
|
||||
/// Defaults:
|
||||
/// - Source DB: `~/ADHD_Scheduler/scheduler.sqlite`
|
||||
/// - Backup dir: `~/ADHD_Scheduler/backups`
|
||||
/// - File name: `yyyymmdd_hhmm.db.enc`
|
||||
Future<File> createEncryptedBackup({
|
||||
required String passphrase,
|
||||
File? sqliteFile,
|
||||
Directory? backupDirectory,
|
||||
DateTime? now,
|
||||
}) async {
|
||||
final source = sqliteFile ?? defaultSchedulerSqliteFile();
|
||||
if (!await source.exists()) {
|
||||
throw BackupException('SQLite file does not exist: ${source.path}');
|
||||
}
|
||||
|
||||
final destinationDirectory =
|
||||
backupDirectory ?? defaultSchedulerBackupDirectory();
|
||||
await destinationDirectory.create(recursive: true);
|
||||
|
||||
final timestamp = _backupTimestamp(now ?? DateTime.now());
|
||||
final destination = await _nextAvailableBackupFile(
|
||||
destinationDirectory,
|
||||
'$timestamp$_fileExtension',
|
||||
);
|
||||
|
||||
final plaintext = await source.readAsBytes();
|
||||
final encoded = await _encryptBytes(
|
||||
passphrase: passphrase,
|
||||
plaintext: plaintext,
|
||||
);
|
||||
return destination.writeAsBytes(encoded, flush: true);
|
||||
}
|
||||
|
||||
/// Restore an encrypted backup over the scheduler SQLite file.
|
||||
///
|
||||
/// Defaults to restoring into `~/ADHD_Scheduler/scheduler.sqlite`. Tests and
|
||||
/// tools can pass [targetFile] to restore into a temp or alternate database.
|
||||
Future<void> restoreEncryptedBackup(
|
||||
File backup,
|
||||
String passphrase, {
|
||||
File? targetFile,
|
||||
}) async {
|
||||
if (!await backup.exists()) {
|
||||
throw BackupException('Backup file does not exist: ${backup.path}');
|
||||
}
|
||||
|
||||
final plaintext = await _decryptBytes(
|
||||
passphrase: passphrase,
|
||||
encoded: await backup.readAsBytes(),
|
||||
);
|
||||
final target = targetFile ?? defaultSchedulerSqliteFile();
|
||||
await target.parent.create(recursive: true);
|
||||
|
||||
final temporary = File('${target.path}.restore.tmp');
|
||||
await temporary.writeAsBytes(plaintext, flush: true);
|
||||
if (await target.exists()) {
|
||||
await target.delete();
|
||||
}
|
||||
await temporary.rename(target.path);
|
||||
}
|
||||
|
||||
/// Default scheduler SQLite database path.
|
||||
File defaultSchedulerSqliteFile() {
|
||||
return File(_joinPath(
|
||||
_homeDirectory().path,
|
||||
defaultSchedulerDirectoryName,
|
||||
defaultSchedulerDatabaseFileName,
|
||||
));
|
||||
}
|
||||
|
||||
/// Default encrypted backup output directory.
|
||||
Directory defaultSchedulerBackupDirectory() {
|
||||
return Directory(_joinPath(
|
||||
_homeDirectory().path,
|
||||
defaultSchedulerDirectoryName,
|
||||
defaultSchedulerBackupDirectoryName,
|
||||
));
|
||||
}
|
||||
|
||||
Future<List<int>> _encryptBytes({
|
||||
required String passphrase,
|
||||
required List<int> plaintext,
|
||||
}) async {
|
||||
final salt = _randomBytes(_saltLength);
|
||||
final nonce = _randomBytes(_nonceLength);
|
||||
final key = await _deriveKey(passphrase: passphrase, salt: salt);
|
||||
final algorithm = AesGcm.with256bits();
|
||||
final secretBox = await algorithm.encrypt(
|
||||
plaintext,
|
||||
secretKey: key,
|
||||
nonce: nonce,
|
||||
);
|
||||
final envelope = <String, Object?>{
|
||||
'magic': _formatMagic,
|
||||
'kdf': 'pbkdf2-hmac-sha256',
|
||||
'iterations': _kdfIterations,
|
||||
'cipher': 'aes-256-gcm',
|
||||
'salt': base64Encode(salt),
|
||||
'nonce': base64Encode(secretBox.nonce),
|
||||
'ciphertext': base64Encode(secretBox.cipherText),
|
||||
'mac': base64Encode(secretBox.mac.bytes),
|
||||
};
|
||||
return _utf8.convert(jsonEncode(envelope));
|
||||
}
|
||||
|
||||
Future<List<int>> _decryptBytes({
|
||||
required String passphrase,
|
||||
required List<int> encoded,
|
||||
}) async {
|
||||
try {
|
||||
final envelope = jsonDecode(utf8.decode(encoded)) as Map<String, Object?>;
|
||||
if (envelope['magic'] != _formatMagic ||
|
||||
envelope['kdf'] != 'pbkdf2-hmac-sha256' ||
|
||||
envelope['iterations'] != _kdfIterations ||
|
||||
envelope['cipher'] != 'aes-256-gcm') {
|
||||
throw const BackupDecryptionException('Unsupported backup format.');
|
||||
}
|
||||
|
||||
final salt = base64Decode(envelope['salt'] as String);
|
||||
final nonce = base64Decode(envelope['nonce'] as String);
|
||||
final cipherText = base64Decode(envelope['ciphertext'] as String);
|
||||
final mac = Mac(base64Decode(envelope['mac'] as String));
|
||||
final key = await _deriveKey(passphrase: passphrase, salt: salt);
|
||||
final algorithm = AesGcm.with256bits();
|
||||
return await algorithm.decrypt(
|
||||
SecretBox(cipherText, nonce: nonce, mac: mac),
|
||||
secretKey: key,
|
||||
);
|
||||
} on BackupDecryptionException {
|
||||
rethrow;
|
||||
} on Object {
|
||||
throw const BackupDecryptionException();
|
||||
}
|
||||
}
|
||||
|
||||
Future<SecretKey> _deriveKey({
|
||||
required String passphrase,
|
||||
required List<int> salt,
|
||||
}) {
|
||||
final algorithm = Pbkdf2(
|
||||
macAlgorithm: Hmac.sha256(),
|
||||
iterations: _kdfIterations,
|
||||
bits: _keyLength * 8,
|
||||
);
|
||||
return algorithm.deriveKey(
|
||||
secretKey: SecretKey(utf8.encode(passphrase)),
|
||||
nonce: salt,
|
||||
);
|
||||
}
|
||||
|
||||
List<int> _randomBytes(int length) {
|
||||
final random = Random.secure();
|
||||
return Uint8List.fromList(
|
||||
List<int>.generate(length, (_) => random.nextInt(256)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<File> _nextAvailableBackupFile(Directory directory, String name) async {
|
||||
final preferred = File(_joinPath(directory.path, name));
|
||||
if (!await preferred.exists()) {
|
||||
return preferred;
|
||||
}
|
||||
final dot = name.lastIndexOf('.');
|
||||
final stem = dot == -1 ? name : name.substring(0, dot);
|
||||
final extension = dot == -1 ? '' : name.substring(dot);
|
||||
var counter = 2;
|
||||
while (true) {
|
||||
final candidate =
|
||||
File(_joinPath(directory.path, '$stem-$counter$extension'));
|
||||
if (!await candidate.exists()) {
|
||||
return candidate;
|
||||
}
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
String _backupTimestamp(DateTime value) {
|
||||
final local = value.toLocal();
|
||||
return '${_four(local.year)}${_two(local.month)}${_two(local.day)}_'
|
||||
'${_two(local.hour)}${_two(local.minute)}';
|
||||
}
|
||||
|
||||
String _four(int value) => value.toString().padLeft(4, '0');
|
||||
|
||||
String _two(int value) => value.toString().padLeft(2, '0');
|
||||
|
||||
Directory _homeDirectory() {
|
||||
final home = Platform.environment['HOME'] ??
|
||||
Platform.environment['USERPROFILE'] ??
|
||||
Platform.environment['HOMEDRIVE'];
|
||||
if (home == null || home.trim().isEmpty) {
|
||||
throw const BackupException('Home directory could not be resolved.');
|
||||
}
|
||||
return Directory(home);
|
||||
}
|
||||
|
||||
String _joinPath(String first, String second, [String? third]) {
|
||||
final separator = Platform.pathSeparator;
|
||||
final left = first.endsWith(separator)
|
||||
? first.substring(0, first.length - separator.length)
|
||||
: first;
|
||||
final middle = second.startsWith(separator) ? second.substring(1) : second;
|
||||
final joined = '$left$separator$middle';
|
||||
if (third == null) {
|
||||
return joined;
|
||||
}
|
||||
final right = third.startsWith(separator) ? third.substring(1) : third;
|
||||
return '$joined$separator$right';
|
||||
}
|
||||
16
packages/scheduler_backup/pubspec.yaml
Normal file
16
packages/scheduler_backup/pubspec.yaml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
name: scheduler_backup
|
||||
description: Encrypted SQLite backup and restore helpers for the ADHD scheduler.
|
||||
version: 0.1.0
|
||||
publish_to: none
|
||||
resolution: workspace
|
||||
|
||||
environment:
|
||||
sdk: '>=3.6.0 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
cryptography: ^2.7.0
|
||||
encrypt: ^5.0.3
|
||||
|
||||
dev_dependencies:
|
||||
lints: ^5.0.0
|
||||
test: ^1.25.0
|
||||
87
packages/scheduler_backup/test/encrypted_backup_test.dart
Normal file
87
packages/scheduler_backup/test/encrypted_backup_test.dart
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:scheduler_backup/backup.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
test('encrypted backup round trips a SQLite file using temp paths', () async {
|
||||
final temp =
|
||||
await Directory.systemTemp.createTemp('scheduler-backup-test-');
|
||||
addTearDown(() => temp.delete(recursive: true));
|
||||
final sqlite =
|
||||
File('${temp.path}${Platform.pathSeparator}scheduler.sqlite');
|
||||
final backupDirectory =
|
||||
Directory('${temp.path}${Platform.pathSeparator}backups');
|
||||
final restored =
|
||||
File('${temp.path}${Platform.pathSeparator}restored.sqlite');
|
||||
const sqliteBytes = <int>[
|
||||
0x53,
|
||||
0x51,
|
||||
0x4c,
|
||||
0x69,
|
||||
0x74,
|
||||
0x65,
|
||||
0x20,
|
||||
0x66,
|
||||
0x6f,
|
||||
0x72,
|
||||
0x6d,
|
||||
0x61,
|
||||
0x74,
|
||||
0x20,
|
||||
0x33,
|
||||
0x00,
|
||||
0x01,
|
||||
0x02,
|
||||
0x03,
|
||||
];
|
||||
await sqlite.writeAsBytes(sqliteBytes);
|
||||
|
||||
final backup = await createEncryptedBackup(
|
||||
passphrase: 'correct horse battery staple',
|
||||
sqliteFile: sqlite,
|
||||
backupDirectory: backupDirectory,
|
||||
now: DateTime(2026, 6, 26, 9, 7),
|
||||
);
|
||||
|
||||
expect(backup.path, endsWith('20260626_0907.db.enc'));
|
||||
expect(await backup.exists(), isTrue);
|
||||
expect(await backup.readAsBytes(), isNot(sqliteBytes));
|
||||
final envelope =
|
||||
jsonDecode(await backup.readAsString()) as Map<String, Object?>;
|
||||
expect(envelope['cipher'], 'aes-256-gcm');
|
||||
|
||||
await restoreEncryptedBackup(
|
||||
backup,
|
||||
'correct horse battery staple',
|
||||
targetFile: restored,
|
||||
);
|
||||
|
||||
expect(await restored.readAsBytes(), sqliteBytes);
|
||||
});
|
||||
|
||||
test('wrong passphrase throws BackupDecryptionException', () async {
|
||||
final temp =
|
||||
await Directory.systemTemp.createTemp('scheduler-backup-test-');
|
||||
addTearDown(() => temp.delete(recursive: true));
|
||||
final sqlite =
|
||||
File('${temp.path}${Platform.pathSeparator}scheduler.sqlite');
|
||||
await sqlite.writeAsBytes(utf8.encode('sqlite bytes'));
|
||||
final backup = await createEncryptedBackup(
|
||||
passphrase: 'right-passphrase',
|
||||
sqliteFile: sqlite,
|
||||
backupDirectory: temp,
|
||||
now: DateTime(2026, 6, 26, 9, 7),
|
||||
);
|
||||
|
||||
expect(
|
||||
() => restoreEncryptedBackup(
|
||||
backup,
|
||||
'wrong-passphrase',
|
||||
targetFile: File('${temp.path}${Platform.pathSeparator}restore.sqlite'),
|
||||
),
|
||||
throwsA(isA<BackupDecryptionException>()),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ export 'src/project_statistics.dart';
|
|||
export 'src/quick_capture.dart';
|
||||
export 'src/reminder_policy.dart';
|
||||
export 'src/repositories.dart';
|
||||
export 'src/repository_values.dart';
|
||||
export 'src/scheduling_engine.dart';
|
||||
export 'src/task_actions.dart';
|
||||
export 'src/task_lifecycle.dart';
|
||||
|
|
@ -325,6 +325,7 @@ class V1ApplicationCommandUseCases {
|
|||
required ApplicationOperationContext context,
|
||||
required CivilDate localDate,
|
||||
required String taskId,
|
||||
int? durationMinutes,
|
||||
DateTime? expectedUpdatedAt,
|
||||
}) {
|
||||
const commandCode =
|
||||
|
|
@ -348,6 +349,20 @@ class V1ApplicationCommandUseCases {
|
|||
if (staleFailure != null) {
|
||||
return ApplicationResult.failure(staleFailure);
|
||||
}
|
||||
if (durationMinutes != null && durationMinutes <= 0) {
|
||||
return _failure(
|
||||
ApplicationFailureCode.validation,
|
||||
entityId: taskId,
|
||||
detailCode: DomainValidationCode.nonPositiveDuration.name,
|
||||
);
|
||||
}
|
||||
if (durationMinutes != null) {
|
||||
task = task.copyWith(
|
||||
durationMinutes: durationMinutes,
|
||||
updatedAt: context.now,
|
||||
);
|
||||
state = state.replaceTask(task);
|
||||
}
|
||||
|
||||
final schedulingResult =
|
||||
const SchedulingEngine().insertBacklogTaskIntoNextAvailableSlot(
|
||||
|
|
@ -1205,6 +1220,14 @@ class _PlanningState {
|
|||
lockedIntervals: lockedIntervals,
|
||||
);
|
||||
}
|
||||
|
||||
_PlanningState replaceTask(Task replacement) {
|
||||
return _PlanningState(
|
||||
tasks: _replaceTask(tasks, replacement),
|
||||
window: window,
|
||||
lockedIntervals: lockedIntervals,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ApplicationResult<ApplicationCommandResult> _failure(
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
//
|
||||
// This layer sits above the pure domain services and repository interfaces. It
|
||||
// gives future Flutter/use-case code one atomic boundary per user command
|
||||
// without importing widgets, MongoDB clients, or platform notification APIs.
|
||||
// without importing widgets, database clients, or platform notification APIs.
|
||||
|
||||
import 'backlog.dart';
|
||||
import 'locked_time.dart';
|
||||
|
|
@ -139,7 +139,7 @@ class ChildTaskBreakUpResult {
|
|||
class ChildTaskBreakUpService {
|
||||
const ChildTaskBreakUpService();
|
||||
|
||||
/// Break [request.parentTaskId] into direct child tasks.
|
||||
/// Break `request.parentTaskId` into direct child tasks.
|
||||
///
|
||||
/// This service validates the full requested set before returning any
|
||||
/// mutations. Persistence and transaction wiring are handled by later blocks.
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// MongoDB-friendly V1 document mapping helpers.
|
||||
// Adapter-neutral V1 document mapping helpers.
|
||||
//
|
||||
// These helpers use plain Dart map shapes only. They do not import MongoDB APIs,
|
||||
// create clients, or perform database I/O.
|
||||
// These helpers use plain Dart map shapes only. They do not import database
|
||||
// APIs, create clients, or perform database I/O.
|
||||
|
||||
import 'application_layer.dart';
|
||||
import 'backlog.dart';
|
||||
|
|
@ -1,15 +1,15 @@
|
|||
// MongoDB-oriented persistence contract helpers.
|
||||
// Adapter-neutral persistence contract helpers.
|
||||
//
|
||||
// This file defines stable field names and encoding conventions that document
|
||||
// codecs and future adapters should use. It deliberately does not serialize
|
||||
// domain models or import a MongoDB client.
|
||||
// domain models or import a database client.
|
||||
|
||||
import 'time_contracts.dart';
|
||||
|
||||
/// V1 document-schema version.
|
||||
const v1SchemaVersion = 1;
|
||||
|
||||
/// Stable V1 collection names for future MongoDB adapters.
|
||||
/// Stable V1 logical collection names for persistence adapters.
|
||||
abstract final class PersistenceCollections {
|
||||
static const tasks = 'tasks';
|
||||
static const projects = 'projects';
|
||||
|
|
@ -553,9 +553,9 @@ extension PersistenceEnumName on Enum {
|
|||
String get persistenceName => name;
|
||||
}
|
||||
|
||||
/// Shared MongoDB document field names.
|
||||
/// Shared V1 document field names.
|
||||
abstract final class DocumentFields {
|
||||
/// MongoDB primary id field.
|
||||
/// Primary id field.
|
||||
static const id = '_id';
|
||||
|
||||
/// Version of the document shape.
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
// Persistence boundary interfaces for the scheduling core.
|
||||
//
|
||||
// These interfaces are intentionally database-client-free. A future MongoDB
|
||||
// adapter can implement them with document storage while the domain layer and
|
||||
// tests keep using the same pure Dart contracts.
|
||||
// These interfaces are intentionally database-client-free. SQLite, in-memory,
|
||||
// and future adapters implement the same pure Dart contracts.
|
||||
|
||||
import 'locked_time.dart';
|
||||
import 'models.dart';
|
||||
92
packages/scheduler_core/lib/src/repository_values.dart
Normal file
92
packages/scheduler_core/lib/src/repository_values.dart
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/// Shared repository value objects used by persistence adapters.
|
||||
///
|
||||
/// These types are deliberately small and database-agnostic. They live in the
|
||||
/// core package so every persistence adapter can use the same owner, revision,
|
||||
/// and paging contracts without importing a storage implementation.
|
||||
library;
|
||||
|
||||
/// Stable owner scope for repository operations.
|
||||
class OwnerId {
|
||||
OwnerId(String value) : value = _requiredTrimmed(value, 'ownerId');
|
||||
|
||||
/// Raw owner identifier.
|
||||
final String value;
|
||||
|
||||
@override
|
||||
String toString() => value;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is OwnerId && other.value == value;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => value.hashCode;
|
||||
}
|
||||
|
||||
/// Optimistic concurrency revision for mutable repository records.
|
||||
class Revision implements Comparable<Revision> {
|
||||
const Revision(this.value) : assert(value >= 0);
|
||||
|
||||
/// First revision assigned to a newly inserted record.
|
||||
static const initial = Revision(1);
|
||||
|
||||
/// Numeric revision value.
|
||||
final int value;
|
||||
|
||||
/// Return the next monotonically increasing revision.
|
||||
Revision next() => Revision(value + 1);
|
||||
|
||||
@override
|
||||
int compareTo(Revision other) => value.compareTo(other.value);
|
||||
|
||||
@override
|
||||
String toString() => value.toString();
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is Revision && other.value == value;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => value.hashCode;
|
||||
}
|
||||
|
||||
/// Cursor contract for bounded repository queries.
|
||||
class PageRequest {
|
||||
const PageRequest({
|
||||
this.cursor,
|
||||
this.limit = 100,
|
||||
}) : assert(limit > 0);
|
||||
|
||||
/// Adapter-neutral cursor. Implementations choose the cursor format.
|
||||
final String? cursor;
|
||||
|
||||
/// Maximum number of records to return.
|
||||
final int limit;
|
||||
}
|
||||
|
||||
/// One bounded page of repository query results.
|
||||
class Page<T> {
|
||||
Page({
|
||||
required Iterable<T> items,
|
||||
this.nextCursor,
|
||||
}) : items = List<T>.unmodifiable(items);
|
||||
|
||||
/// Items returned for this page.
|
||||
final List<T> items;
|
||||
|
||||
/// Cursor for the next page, or null when this is the last page.
|
||||
final String? nextCursor;
|
||||
|
||||
/// Whether another page is available.
|
||||
bool get hasMore => nextCursor != null;
|
||||
}
|
||||
|
||||
String _requiredTrimmed(String value, String name) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
throw ArgumentError.value(value, name, 'Value must not be blank.');
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
|
@ -449,7 +449,7 @@ class SchedulingEngine {
|
|||
/// flexible tasks at or after the insertion point may shift later, preserving
|
||||
/// their relative order.
|
||||
///
|
||||
/// The selected task must already exist in [input.tasks], be flexible, be in
|
||||
/// The selected task must already exist in `input.tasks`, be flexible, be in
|
||||
/// backlog status, and have a positive duration. If any precondition fails, the
|
||||
/// original task list is returned with a no-fit/overflow notice.
|
||||
SchedulingResult insertBacklogTaskIntoNextAvailableSlot({
|
||||
|
|
@ -656,7 +656,7 @@ class SchedulingEngine {
|
|||
/// flexible tasks in that window may shift later, preserving their order.
|
||||
///
|
||||
/// The method name says "tomorrow" because that is the product action, but the
|
||||
/// engine only trusts [input.window]. Tests can pass any future window.
|
||||
/// engine only trusts `input.window`. Tests can pass any future window.
|
||||
SchedulingResult pushFlexibleTaskToTomorrowTopOfQueue({
|
||||
required SchedulingInput input,
|
||||
required String taskId,
|
||||
|
|
@ -132,7 +132,7 @@ class CompactTimelineState {
|
|||
/// Whether the full timeline is expanded while compact mode is active.
|
||||
final bool fullTimelineExpanded;
|
||||
|
||||
/// Current visible task, if one is active or occupies [now].
|
||||
/// Current visible task, if one is active or occupies the query time.
|
||||
final TimelineItem? currentItem;
|
||||
|
||||
/// Next critical or inflexible task after the current moment.
|
||||
12
packages/scheduler_core/pubspec.yaml
Normal file
12
packages/scheduler_core/pubspec.yaml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
name: scheduler_core
|
||||
description: A pure Dart scheduling core starter for an ADHD-focused scheduling app.
|
||||
version: 0.1.0
|
||||
publish_to: none
|
||||
resolution: workspace
|
||||
|
||||
environment:
|
||||
sdk: '>=3.6.0 <4.0.0'
|
||||
|
||||
dev_dependencies:
|
||||
lints: ^5.0.0
|
||||
test: ^1.25.0
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -105,6 +105,36 @@ void main() {
|
|||
]);
|
||||
});
|
||||
|
||||
test('schedules backlog item after duration is supplied', () async {
|
||||
final backlog = Task.quickCapture(
|
||||
id: 'backlog',
|
||||
title: 'Backlog',
|
||||
projectId: 'home',
|
||||
createdAt: createdAt,
|
||||
updatedAt: createdAt,
|
||||
);
|
||||
final store = storeWithSettings(tasks: [backlog]);
|
||||
|
||||
final result =
|
||||
await commands(store).scheduleBacklogItemToNextAvailableSlot(
|
||||
context: appContext(
|
||||
'schedule-backlog-with-duration',
|
||||
DateTime.utc(2026, 6, 25, 8),
|
||||
),
|
||||
localDate: day,
|
||||
taskId: backlog.id,
|
||||
durationMinutes: 25,
|
||||
expectedUpdatedAt: backlog.updatedAt,
|
||||
);
|
||||
|
||||
expect(result.isSuccess, isTrue);
|
||||
final scheduled = taskById(store.currentTasks, 'backlog');
|
||||
expect(scheduled.durationMinutes, 25);
|
||||
expect(scheduled.status, TaskStatus.planned);
|
||||
expect(scheduled.scheduledStart, DateTime.utc(2026, 6, 25, 9));
|
||||
expect(scheduled.scheduledEnd, DateTime.utc(2026, 6, 25, 9, 25));
|
||||
});
|
||||
|
||||
test('stale expected update leaves repositories unchanged', () async {
|
||||
final planned = task(
|
||||
id: 'planned',
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('MongoDB V1 document codecs', () {
|
||||
group('V1 document codecs', () {
|
||||
final createdAt = DateTime.utc(2026, 6, 23, 8);
|
||||
final updatedAt = DateTime.utc(2026, 6, 23, 8, 5);
|
||||
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -231,7 +231,10 @@ void main() {
|
|||
}
|
||||
|
||||
Map<String, Object?> fixture(String name) {
|
||||
final file = File('test/fixtures/migration/$name');
|
||||
var file = File('test/fixtures/migration/$name');
|
||||
if (!file.existsSync()) {
|
||||
file = File('packages/scheduler_core/test/fixtures/migration/$name');
|
||||
}
|
||||
final decoded = jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
|
||||
return Map<String, Object?>.from(decoded);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
1
packages/scheduler_export/analysis_options.yaml
Normal file
1
packages/scheduler_export/analysis_options.yaml
Normal file
|
|
@ -0,0 +1 @@
|
|||
include: package:lints/recommended.yaml
|
||||
4
packages/scheduler_export/lib/export.dart
Normal file
4
packages/scheduler_export/lib/export.dart
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/// Readable export contracts and controller for scheduler data.
|
||||
library;
|
||||
|
||||
export 'src/export_controller.dart';
|
||||
4
packages/scheduler_export/lib/scheduler_export.dart
Normal file
4
packages/scheduler_export/lib/scheduler_export.dart
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/// Package-name entry point for scheduler export contracts.
|
||||
library;
|
||||
|
||||
export 'export.dart';
|
||||
303
packages/scheduler_export/lib/src/export_controller.dart
Normal file
303
packages/scheduler_export/lib/src/export_controller.dart
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:scheduler_core/scheduler_core.dart' as core;
|
||||
import 'package:scheduler_persistence/persistence.dart';
|
||||
|
||||
/// Export metadata shared with every writer.
|
||||
final class ExportContext {
|
||||
/// Creates an export context.
|
||||
ExportContext({
|
||||
required this.ownerId,
|
||||
required String timezone,
|
||||
required this.version,
|
||||
}) : timezone = _requiredTrimmed(timezone, 'timezone');
|
||||
|
||||
/// Owner scope being exported.
|
||||
final core.OwnerId ownerId;
|
||||
|
||||
/// Timezone id used to interpret local export presentation.
|
||||
final String timezone;
|
||||
|
||||
/// Export format/schema version.
|
||||
final int version;
|
||||
}
|
||||
|
||||
/// Thrown when an export cannot complete.
|
||||
final class ExportException implements Exception {
|
||||
/// Creates an export exception with a diagnostic [message].
|
||||
const ExportException(this.message);
|
||||
|
||||
/// Diagnostic text for logs and tests.
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => 'ExportException: $message';
|
||||
}
|
||||
|
||||
/// Thrown when repository reads fail during export.
|
||||
final class ExportRepositoryException extends ExportException {
|
||||
/// Creates an exception wrapping a repository [failure].
|
||||
ExportRepositoryException(this.failure)
|
||||
: super('Repository export read failed: ${failure.runtimeType}');
|
||||
|
||||
/// Repository failure returned by an adapter.
|
||||
final RepositoryFailure failure;
|
||||
}
|
||||
|
||||
/// Writer interface for readable exports.
|
||||
abstract interface class ExportWriter {
|
||||
/// Start writing an export for [context].
|
||||
Future<void> begin(ExportContext context);
|
||||
|
||||
/// Write one task record.
|
||||
Future<void> writeTask(core.Task task);
|
||||
|
||||
/// Finish writing the export.
|
||||
Future<void> end();
|
||||
}
|
||||
|
||||
/// Factory signature for sink-backed export writers.
|
||||
typedef ExportWriterFactory = ExportWriter Function(StringSink sink);
|
||||
|
||||
/// Registry that resolves export format names to writer factories.
|
||||
final class ExportWriterRegistry {
|
||||
/// Creates a registry from [factories].
|
||||
ExportWriterRegistry({
|
||||
Map<String, ExportWriterFactory> factories =
|
||||
const <String, ExportWriterFactory>{},
|
||||
}) : _factories = Map<String, ExportWriterFactory>.fromEntries(
|
||||
factories.entries.map(
|
||||
(entry) => MapEntry(_normalizeFormat(entry.key), entry.value),
|
||||
),
|
||||
);
|
||||
|
||||
final Map<String, ExportWriterFactory> _factories;
|
||||
|
||||
/// Default registry with JSON and CSV stub writers.
|
||||
factory ExportWriterRegistry.defaults() {
|
||||
return ExportWriterRegistry(
|
||||
factories: <String, ExportWriterFactory>{
|
||||
'json': JsonExportWriter.new,
|
||||
'csv': CsvExportWriter.new,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Available format names.
|
||||
Set<String> get formats => Set<String>.unmodifiable(_factories.keys);
|
||||
|
||||
/// Register [factory] for [format].
|
||||
void register(String format, ExportWriterFactory factory) {
|
||||
_factories[_normalizeFormat(format)] = factory;
|
||||
}
|
||||
|
||||
/// Create a writer for [format] using [sink].
|
||||
ExportWriter create(String format, StringSink sink) {
|
||||
final normalized = _normalizeFormat(format);
|
||||
final factory = _factories[normalized];
|
||||
if (factory == null) {
|
||||
throw ExportException('Unknown export format: $format');
|
||||
}
|
||||
return factory(sink);
|
||||
}
|
||||
}
|
||||
|
||||
/// Coordinates repository reads and export writer calls.
|
||||
final class ExportController {
|
||||
/// Creates an export controller using [taskRepository].
|
||||
ExportController({
|
||||
required TaskRepository taskRepository,
|
||||
ExportWriterRegistry? writerRegistry,
|
||||
}) : _taskRepository = taskRepository,
|
||||
_writerRegistry = writerRegistry ?? ExportWriterRegistry.defaults();
|
||||
|
||||
final TaskRepository _taskRepository;
|
||||
final ExportWriterRegistry _writerRegistry;
|
||||
|
||||
/// Create a writer for [format] and export owner-scoped tasks to [sink].
|
||||
Future<void> exportTasksToSink({
|
||||
required ExportContext context,
|
||||
required String format,
|
||||
required StringSink sink,
|
||||
int pageSize = 100,
|
||||
}) {
|
||||
final writer = _writerRegistry.create(format, sink);
|
||||
return exportTasks(
|
||||
context: context,
|
||||
writer: writer,
|
||||
pageSize: pageSize,
|
||||
);
|
||||
}
|
||||
|
||||
/// Export all owner-scoped tasks through [writer].
|
||||
Future<void> exportTasks({
|
||||
required ExportContext context,
|
||||
required ExportWriter writer,
|
||||
int pageSize = 100,
|
||||
}) async {
|
||||
if (pageSize <= 0) {
|
||||
throw ArgumentError.value(pageSize, 'pageSize', 'Must be positive.');
|
||||
}
|
||||
|
||||
await writer.begin(context);
|
||||
String? cursor;
|
||||
do {
|
||||
final result = await _taskRepository.findByOwner(
|
||||
ownerId: context.ownerId,
|
||||
page: core.PageRequest(cursor: cursor, limit: pageSize),
|
||||
);
|
||||
if (result.isLeft) {
|
||||
throw ExportRepositoryException(result.left);
|
||||
}
|
||||
final page = result.right;
|
||||
for (final task in page.items) {
|
||||
await writer.writeTask(task);
|
||||
}
|
||||
cursor = page.nextCursor;
|
||||
} while (cursor != null);
|
||||
await writer.end();
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal JSON export writer stub.
|
||||
final class JsonExportWriter implements ExportWriter {
|
||||
/// Creates a JSON writer that writes to the provided string sink.
|
||||
JsonExportWriter(this._sink);
|
||||
|
||||
final StringSink _sink;
|
||||
ExportContext? _context;
|
||||
bool _hasTask = false;
|
||||
bool _ended = false;
|
||||
|
||||
@override
|
||||
Future<void> begin(ExportContext context) async {
|
||||
_assertNotEnded();
|
||||
_context = context;
|
||||
_sink.write(
|
||||
'{"context":${jsonEncode(_contextToJson(context))},"tasks":[',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> writeTask(core.Task task) async {
|
||||
final context = _requireContext();
|
||||
if (_hasTask) {
|
||||
_sink.write(',');
|
||||
}
|
||||
_sink.write(jsonEncode(core.TaskDocumentMapping.toDocument(
|
||||
task,
|
||||
ownerId: context.ownerId.value,
|
||||
)));
|
||||
_hasTask = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> end() async {
|
||||
_requireContext();
|
||||
_assertNotEnded();
|
||||
_sink.write(']}');
|
||||
_ended = true;
|
||||
}
|
||||
|
||||
ExportContext _requireContext() {
|
||||
final context = _context;
|
||||
if (context == null) {
|
||||
throw const ExportException('Writer has not started.');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
void _assertNotEnded() {
|
||||
if (_ended) {
|
||||
throw const ExportException('Writer has already ended.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal CSV export writer stub.
|
||||
final class CsvExportWriter implements ExportWriter {
|
||||
/// Creates a CSV writer that writes to the provided string sink.
|
||||
CsvExportWriter(this._sink);
|
||||
|
||||
final StringSink _sink;
|
||||
bool _started = false;
|
||||
bool _ended = false;
|
||||
|
||||
@override
|
||||
Future<void> begin(ExportContext context) async {
|
||||
_assertNotEnded();
|
||||
_started = true;
|
||||
_sink.writeln(
|
||||
'id,title,projectId,type,status,durationMinutes,scheduledStartUtc,'
|
||||
'scheduledEndUtc',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> writeTask(core.Task task) async {
|
||||
if (!_started) {
|
||||
throw const ExportException('Writer has not started.');
|
||||
}
|
||||
_assertNotEnded();
|
||||
_sink.writeln(<String>[
|
||||
task.id,
|
||||
task.title,
|
||||
task.projectId,
|
||||
core.PersistenceEnumMapping.encodeTaskType(task.type),
|
||||
core.PersistenceEnumMapping.encodeTaskStatus(task.status),
|
||||
task.durationMinutes?.toString() ?? '',
|
||||
task.scheduledStart == null
|
||||
? ''
|
||||
: core.PersistenceDateTimeConvention.toStoredString(
|
||||
task.scheduledStart!,
|
||||
),
|
||||
task.scheduledEnd == null
|
||||
? ''
|
||||
: core.PersistenceDateTimeConvention.toStoredString(
|
||||
task.scheduledEnd!,
|
||||
),
|
||||
].map(_csvCell).join(','));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> end() async {
|
||||
if (!_started) {
|
||||
throw const ExportException('Writer has not started.');
|
||||
}
|
||||
_assertNotEnded();
|
||||
_ended = true;
|
||||
}
|
||||
|
||||
void _assertNotEnded() {
|
||||
if (_ended) {
|
||||
throw const ExportException('Writer has already ended.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object?> _contextToJson(ExportContext context) {
|
||||
return <String, Object?>{
|
||||
'ownerId': context.ownerId.value,
|
||||
'timezone': context.timezone,
|
||||
'version': context.version,
|
||||
};
|
||||
}
|
||||
|
||||
String _csvCell(String value) {
|
||||
final needsQuotes =
|
||||
value.contains(',') || value.contains('"') || value.contains('\n');
|
||||
final escaped = value.replaceAll('"', '""');
|
||||
return needsQuotes ? '"$escaped"' : escaped;
|
||||
}
|
||||
|
||||
String _normalizeFormat(String format) {
|
||||
return _requiredTrimmed(format, 'format').toLowerCase();
|
||||
}
|
||||
|
||||
String _requiredTrimmed(String value, String name) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
throw ArgumentError.value(value, name, 'Value must not be blank.');
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
17
packages/scheduler_export/pubspec.yaml
Normal file
17
packages/scheduler_export/pubspec.yaml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
name: scheduler_export
|
||||
description: Export controller and writer contracts for the ADHD scheduler.
|
||||
version: 0.1.0
|
||||
publish_to: none
|
||||
resolution: workspace
|
||||
|
||||
environment:
|
||||
sdk: '>=3.6.0 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
scheduler_core: any
|
||||
scheduler_persistence: any
|
||||
|
||||
dev_dependencies:
|
||||
lints: ^5.0.0
|
||||
scheduler_persistence_memory: any
|
||||
test: ^1.25.0
|
||||
100
packages/scheduler_export/test/export_controller_test.dart
Normal file
100
packages/scheduler_export/test/export_controller_test.dart
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:scheduler_core/scheduler_core.dart' as core;
|
||||
import 'package:scheduler_export/export.dart';
|
||||
import 'package:scheduler_persistence_memory/persistence_memory.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
test('JSON writer exports owner-scoped tasks through controller', () async {
|
||||
final ownerId = core.OwnerId('owner-a');
|
||||
final repository = InMemoryTaskRepository();
|
||||
await repository.insert(ownerId: ownerId, task: _task('task-b'));
|
||||
await repository.insert(ownerId: ownerId, task: _task('task-a'));
|
||||
await repository.insert(
|
||||
ownerId: core.OwnerId('owner-b'),
|
||||
task: _task('task-hidden'),
|
||||
);
|
||||
final output = StringBuffer();
|
||||
|
||||
await ExportController(taskRepository: repository).exportTasksToSink(
|
||||
context: ExportContext(
|
||||
ownerId: ownerId,
|
||||
timezone: 'America/Los_Angeles',
|
||||
version: 1,
|
||||
),
|
||||
format: 'json',
|
||||
sink: output,
|
||||
pageSize: 1,
|
||||
);
|
||||
|
||||
final decoded = jsonDecode(output.toString()) as Map<String, Object?>;
|
||||
final context = decoded['context'] as Map<String, Object?>;
|
||||
final tasks = decoded['tasks'] as List<Object?>;
|
||||
|
||||
expect(context['ownerId'], 'owner-a');
|
||||
expect(context['timezone'], 'America/Los_Angeles');
|
||||
expect(tasks.map((task) => (task as Map<String, Object?>)['_id']),
|
||||
['task-a', 'task-b']);
|
||||
});
|
||||
|
||||
test('CSV writer exports task rows', () async {
|
||||
final ownerId = core.OwnerId('owner-a');
|
||||
final repository = InMemoryTaskRepository();
|
||||
await repository.insert(ownerId: ownerId, task: _task('task-a'));
|
||||
final output = StringBuffer();
|
||||
|
||||
await ExportController(taskRepository: repository).exportTasksToSink(
|
||||
context: ExportContext(
|
||||
ownerId: ownerId,
|
||||
timezone: 'UTC',
|
||||
version: 1,
|
||||
),
|
||||
format: 'csv',
|
||||
sink: output,
|
||||
);
|
||||
|
||||
final lines = output.toString().trim().split('\n');
|
||||
expect(lines.first, startsWith('id,title,projectId,type,status'));
|
||||
expect(lines.singleWhere((line) => line.startsWith('task-a,')),
|
||||
contains('Task task-a'));
|
||||
});
|
||||
|
||||
test('writer registry supports custom writer factories', () async {
|
||||
final registry = ExportWriterRegistry();
|
||||
registry.register('custom', _CountingWriter.new);
|
||||
final writer = registry.create(' CUSTOM ', StringBuffer());
|
||||
|
||||
expect(writer, isA<_CountingWriter>());
|
||||
});
|
||||
}
|
||||
|
||||
core.Task _task(String id) {
|
||||
return core.Task(
|
||||
id: id,
|
||||
title: 'Task $id',
|
||||
projectId: 'project-a',
|
||||
type: core.TaskType.flexible,
|
||||
status: core.TaskStatus.backlog,
|
||||
durationMinutes: 30,
|
||||
createdAt: DateTime.utc(2026, 1),
|
||||
updatedAt: DateTime.utc(2026, 1),
|
||||
);
|
||||
}
|
||||
|
||||
final class _CountingWriter implements ExportWriter {
|
||||
_CountingWriter(StringSink sink);
|
||||
|
||||
var count = 0;
|
||||
|
||||
@override
|
||||
Future<void> begin(ExportContext context) async {}
|
||||
|
||||
@override
|
||||
Future<void> writeTask(core.Task task) async {
|
||||
count += 1;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> end() async {}
|
||||
}
|
||||
1
packages/scheduler_export_json/analysis_options.yaml
Normal file
1
packages/scheduler_export_json/analysis_options.yaml
Normal file
|
|
@ -0,0 +1 @@
|
|||
include: package:lints/recommended.yaml
|
||||
49
packages/scheduler_export_json/bin/export.dart
Normal file
49
packages/scheduler_export_json/bin/export.dart
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:scheduler_core/scheduler_core.dart' as core;
|
||||
import 'package:scheduler_export/export.dart';
|
||||
import 'package:scheduler_export_json/export_json.dart';
|
||||
import 'package:scheduler_persistence_memory/persistence_memory.dart';
|
||||
|
||||
Future<void> main(List<String> arguments) async {
|
||||
final format = _format(arguments);
|
||||
if (format == null) {
|
||||
stderr.writeln('Usage: dart run bin/export.dart --json|--csv');
|
||||
exitCode = 64;
|
||||
return;
|
||||
}
|
||||
|
||||
final output = StringBuffer();
|
||||
final ownerId =
|
||||
core.OwnerId(_option(arguments, '--owner') ?? 'default-owner');
|
||||
await ExportController(
|
||||
taskRepository: InMemoryTaskRepository(),
|
||||
writerRegistry: readableExportWriterRegistry(),
|
||||
).exportTasksToSink(
|
||||
context: ExportContext(
|
||||
ownerId: ownerId,
|
||||
timezone: _option(arguments, '--timezone') ?? 'UTC',
|
||||
version: 1,
|
||||
),
|
||||
format: format,
|
||||
sink: output,
|
||||
);
|
||||
stdout.write(output.toString());
|
||||
}
|
||||
|
||||
String? _format(List<String> arguments) {
|
||||
final wantsJson = arguments.contains('--json');
|
||||
final wantsCsv = arguments.contains('--csv');
|
||||
if (wantsJson == wantsCsv) {
|
||||
return null;
|
||||
}
|
||||
return wantsJson ? 'json' : 'csv';
|
||||
}
|
||||
|
||||
String? _option(List<String> arguments, String name) {
|
||||
final index = arguments.indexOf(name);
|
||||
if (index == -1 || index + 1 >= arguments.length) {
|
||||
return null;
|
||||
}
|
||||
return arguments[index + 1];
|
||||
}
|
||||
4
packages/scheduler_export_json/lib/export_json.dart
Normal file
4
packages/scheduler_export_json/lib/export_json.dart
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/// JSON and CSV readable export writers.
|
||||
library;
|
||||
|
||||
export 'src/json_csv_writers.dart';
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
/// Package-name entry point for scheduler readable export writers.
|
||||
library;
|
||||
|
||||
export 'export_json.dart';
|
||||
146
packages/scheduler_export_json/lib/src/json_csv_writers.dart
Normal file
146
packages/scheduler_export_json/lib/src/json_csv_writers.dart
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:scheduler_core/scheduler_core.dart' as core;
|
||||
import 'package:scheduler_export/export.dart';
|
||||
|
||||
/// Create the readable export writer registry used by export commands.
|
||||
ExportWriterRegistry readableExportWriterRegistry() {
|
||||
return ExportWriterRegistry(
|
||||
factories: <String, ExportWriterFactory>{
|
||||
'json': JsonExportWriter.new,
|
||||
'csv': CsvExportWriter.new,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// JSON export writer for scheduler tasks.
|
||||
final class JsonExportWriter implements ExportWriter {
|
||||
/// Creates a JSON writer that writes to the provided string sink.
|
||||
JsonExportWriter(this._sink);
|
||||
|
||||
final StringSink _sink;
|
||||
ExportContext? _context;
|
||||
bool _hasTask = false;
|
||||
bool _ended = false;
|
||||
|
||||
@override
|
||||
Future<void> begin(ExportContext context) async {
|
||||
_assertNotEnded();
|
||||
_context = context;
|
||||
_sink.write(
|
||||
'{"context":${jsonEncode(_contextToJson(context))},"tasks":[',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> writeTask(core.Task task) async {
|
||||
final context = _requireContext();
|
||||
_assertNotEnded();
|
||||
if (_hasTask) {
|
||||
_sink.write(',');
|
||||
}
|
||||
_sink.write(jsonEncode(core.TaskDocumentMapping.toDocument(
|
||||
task,
|
||||
ownerId: context.ownerId.value,
|
||||
)));
|
||||
_hasTask = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> end() async {
|
||||
_requireContext();
|
||||
_assertNotEnded();
|
||||
_sink.write(']}');
|
||||
_ended = true;
|
||||
}
|
||||
|
||||
ExportContext _requireContext() {
|
||||
final context = _context;
|
||||
if (context == null) {
|
||||
throw const ExportException('Writer has not started.');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
void _assertNotEnded() {
|
||||
if (_ended) {
|
||||
throw const ExportException('Writer has already ended.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// CSV export writer for scheduler tasks.
|
||||
final class CsvExportWriter implements ExportWriter {
|
||||
/// Creates a CSV writer that writes to the provided string sink.
|
||||
CsvExportWriter(this._sink);
|
||||
|
||||
final StringSink _sink;
|
||||
bool _started = false;
|
||||
bool _ended = false;
|
||||
|
||||
@override
|
||||
Future<void> begin(ExportContext context) async {
|
||||
_assertNotEnded();
|
||||
_started = true;
|
||||
_sink.writeln(
|
||||
'id,title,projectId,type,status,durationMinutes,scheduledStartUtc,'
|
||||
'scheduledEndUtc',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> writeTask(core.Task task) async {
|
||||
if (!_started) {
|
||||
throw const ExportException('Writer has not started.');
|
||||
}
|
||||
_assertNotEnded();
|
||||
_sink.writeln(<String>[
|
||||
task.id,
|
||||
task.title,
|
||||
task.projectId,
|
||||
core.PersistenceEnumMapping.encodeTaskType(task.type),
|
||||
core.PersistenceEnumMapping.encodeTaskStatus(task.status),
|
||||
task.durationMinutes?.toString() ?? '',
|
||||
task.scheduledStart == null
|
||||
? ''
|
||||
: core.PersistenceDateTimeConvention.toStoredString(
|
||||
task.scheduledStart!,
|
||||
),
|
||||
task.scheduledEnd == null
|
||||
? ''
|
||||
: core.PersistenceDateTimeConvention.toStoredString(
|
||||
task.scheduledEnd!,
|
||||
),
|
||||
].map(_csvCell).join(','));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> end() async {
|
||||
if (!_started) {
|
||||
throw const ExportException('Writer has not started.');
|
||||
}
|
||||
_assertNotEnded();
|
||||
_ended = true;
|
||||
}
|
||||
|
||||
void _assertNotEnded() {
|
||||
if (_ended) {
|
||||
throw const ExportException('Writer has already ended.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object?> _contextToJson(ExportContext context) {
|
||||
return <String, Object?>{
|
||||
'ownerId': context.ownerId.value,
|
||||
'timezone': context.timezone,
|
||||
'version': context.version,
|
||||
};
|
||||
}
|
||||
|
||||
String _csvCell(String value) {
|
||||
final needsQuotes =
|
||||
value.contains(',') || value.contains('"') || value.contains('\n');
|
||||
final escaped = value.replaceAll('"', '""');
|
||||
return needsQuotes ? '"$escaped"' : escaped;
|
||||
}
|
||||
17
packages/scheduler_export_json/pubspec.yaml
Normal file
17
packages/scheduler_export_json/pubspec.yaml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
name: scheduler_export_json
|
||||
description: JSON and CSV readable export writers for the ADHD scheduler.
|
||||
version: 0.1.0
|
||||
publish_to: none
|
||||
resolution: workspace
|
||||
|
||||
environment:
|
||||
sdk: '>=3.6.0 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
scheduler_core: any
|
||||
scheduler_export: any
|
||||
scheduler_persistence_memory: any
|
||||
|
||||
dev_dependencies:
|
||||
lints: ^5.0.0
|
||||
test: ^1.25.0
|
||||
94
packages/scheduler_export_json/test/export_json_test.dart
Normal file
94
packages/scheduler_export_json/test/export_json_test.dart
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:scheduler_core/scheduler_core.dart' as core;
|
||||
import 'package:scheduler_export/export.dart';
|
||||
import 'package:scheduler_export_json/export_json.dart';
|
||||
import 'package:scheduler_persistence_memory/persistence_memory.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
test('readable registry exports valid JSON through ExportController',
|
||||
() async {
|
||||
final ownerId = core.OwnerId('owner-a');
|
||||
final repository = InMemoryTaskRepository();
|
||||
await repository.insert(ownerId: ownerId, task: _task('task-a'));
|
||||
final output = StringBuffer();
|
||||
|
||||
await ExportController(
|
||||
taskRepository: repository,
|
||||
writerRegistry: readableExportWriterRegistry(),
|
||||
).exportTasksToSink(
|
||||
context: ExportContext(ownerId: ownerId, timezone: 'UTC', version: 1),
|
||||
format: 'json',
|
||||
sink: output,
|
||||
);
|
||||
|
||||
final decoded = jsonDecode(output.toString()) as Map<String, Object?>;
|
||||
expect(decoded['context'], isA<Map<String, Object?>>());
|
||||
final tasks = decoded['tasks'] as List<Object?>;
|
||||
expect(tasks, hasLength(1));
|
||||
expect((tasks.single as Map<String, Object?>)['_id'], 'task-a');
|
||||
});
|
||||
|
||||
test('CSV writer quotes cells with commas and quotes', () async {
|
||||
final ownerId = core.OwnerId('owner-a');
|
||||
final repository = InMemoryTaskRepository();
|
||||
await repository.insert(
|
||||
ownerId: ownerId,
|
||||
task: _task('task-a', title: 'Task, "quoted"'),
|
||||
);
|
||||
final output = StringBuffer();
|
||||
|
||||
await ExportController(
|
||||
taskRepository: repository,
|
||||
writerRegistry: readableExportWriterRegistry(),
|
||||
).exportTasksToSink(
|
||||
context: ExportContext(ownerId: ownerId, timezone: 'UTC', version: 1),
|
||||
format: 'csv',
|
||||
sink: output,
|
||||
);
|
||||
|
||||
expect(output.toString(), contains('"Task, ""quoted"""'));
|
||||
});
|
||||
|
||||
test('export command --json outputs valid JSON', () async {
|
||||
final result = await Process.run(
|
||||
Platform.resolvedExecutable,
|
||||
<String>['run', 'bin/export.dart', '--json'],
|
||||
workingDirectory: _packageDirectory().path,
|
||||
);
|
||||
|
||||
expect(result.exitCode, 0, reason: result.stderr as String?);
|
||||
final decoded = jsonDecode(result.stdout as String) as Map<String, Object?>;
|
||||
expect(decoded['tasks'], isA<List<Object?>>());
|
||||
}, timeout: const Timeout(Duration(seconds: 20)));
|
||||
}
|
||||
|
||||
Directory _packageDirectory() {
|
||||
final currentPubspec =
|
||||
File('${Directory.current.path}${Platform.pathSeparator}pubspec.yaml');
|
||||
if (currentPubspec.existsSync() &&
|
||||
currentPubspec
|
||||
.readAsStringSync()
|
||||
.contains('name: scheduler_export_json')) {
|
||||
return Directory.current;
|
||||
}
|
||||
return Directory(
|
||||
'${Directory.current.path}${Platform.pathSeparator}packages'
|
||||
'${Platform.pathSeparator}scheduler_export_json',
|
||||
);
|
||||
}
|
||||
|
||||
core.Task _task(String id, {String? title}) {
|
||||
return core.Task(
|
||||
id: id,
|
||||
title: title ?? 'Task $id',
|
||||
projectId: 'project-a',
|
||||
type: core.TaskType.flexible,
|
||||
status: core.TaskStatus.backlog,
|
||||
durationMinutes: 30,
|
||||
createdAt: DateTime.utc(2026, 1),
|
||||
updatedAt: DateTime.utc(2026, 1),
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
include: package:lints/recommended.yaml
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
import 'package:drift/native.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart' as core;
|
||||
import 'package:scheduler_notifications/notifications.dart';
|
||||
import 'package:scheduler_persistence_memory/persistence_memory.dart';
|
||||
import 'package:scheduler_persistence_sqlite/sqlite.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
test('scheduler flow schedules, notifies, persists, and reloads', () async {
|
||||
final runtime = Stopwatch()..start();
|
||||
addTearDown(() {
|
||||
runtime.stop();
|
||||
expect(runtime.elapsed, lessThan(const Duration(seconds: 2)));
|
||||
});
|
||||
final ownerId = core.OwnerId('owner-a');
|
||||
final now = DateTime.utc(2026, 6, 26, 8);
|
||||
final window = core.SchedulingWindow(
|
||||
start: DateTime.utc(2026, 6, 26, 9),
|
||||
end: DateTime.utc(2026, 6, 26, 10, 30),
|
||||
);
|
||||
final lockedInterval = core.TimeInterval(
|
||||
start: DateTime.utc(2026, 6, 26, 9),
|
||||
end: DateTime.utc(2026, 6, 26, 9, 15),
|
||||
label: 'Protected setup time',
|
||||
);
|
||||
final backlogTask = core.Task(
|
||||
id: 'backlog-a',
|
||||
title: 'Write planning note',
|
||||
projectId: 'project-a',
|
||||
type: core.TaskType.flexible,
|
||||
status: core.TaskStatus.backlog,
|
||||
durationMinutes: 15,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
final plannedTask = core.Task(
|
||||
id: 'planned-a',
|
||||
title: 'Existing focus block',
|
||||
projectId: 'project-a',
|
||||
type: core.TaskType.flexible,
|
||||
status: core.TaskStatus.planned,
|
||||
durationMinutes: 30,
|
||||
scheduledStart: DateTime.utc(2026, 6, 26, 9, 30),
|
||||
scheduledEnd: DateTime.utc(2026, 6, 26, 10),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
|
||||
final memoryTasks = InMemoryTaskRepository();
|
||||
expect(
|
||||
(await memoryTasks.insert(ownerId: ownerId, task: backlogTask)).isRight,
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
(await memoryTasks.insert(ownerId: ownerId, task: plannedTask)).isRight,
|
||||
isTrue,
|
||||
);
|
||||
|
||||
final loaded = await memoryTasks.findByOwner(ownerId: ownerId);
|
||||
expect(loaded.isRight, isTrue);
|
||||
final result =
|
||||
const core.SchedulingEngine().insertBacklogTaskIntoNextAvailableSlot(
|
||||
input: core.SchedulingInput(
|
||||
tasks: loaded.right.items,
|
||||
window: window,
|
||||
lockedIntervals: [lockedInterval],
|
||||
),
|
||||
taskId: backlogTask.id,
|
||||
updatedAt: now,
|
||||
);
|
||||
|
||||
expect(result.outcomeCode, core.SchedulingOutcomeCode.success);
|
||||
final scheduledBacklog = _taskById(result.tasks, backlogTask.id);
|
||||
expect(scheduledBacklog.status, core.TaskStatus.planned);
|
||||
expect(scheduledBacklog.scheduledStart, lockedInterval.end);
|
||||
expect(
|
||||
scheduledBacklog.scheduledEnd,
|
||||
DateTime.utc(2026, 6, 26, 9, 30),
|
||||
);
|
||||
|
||||
final notifications = FakeNotificationAdapter();
|
||||
addTearDown(notifications.close);
|
||||
await notifications.schedule(NotificationRequest(
|
||||
id: 'task-${scheduledBacklog.id}-start',
|
||||
title: scheduledBacklog.title,
|
||||
body: 'Scheduled task is ready.',
|
||||
scheduledDateTimeUtc: scheduledBacklog.scheduledStart!,
|
||||
payloadJson: '{"taskId":"${scheduledBacklog.id}"}',
|
||||
));
|
||||
expect(
|
||||
notifications.scheduledRequests.keys,
|
||||
contains('task-backlog-a-start'),
|
||||
);
|
||||
|
||||
final db = SchedulerDb(NativeDatabase.memory());
|
||||
addTearDown(db.close);
|
||||
final sqliteTasks = SqliteTaskRepository(db);
|
||||
for (final task in result.tasks) {
|
||||
final inserted = await sqliteTasks.insert(ownerId: ownerId, task: task);
|
||||
expect(inserted.isRight, isTrue);
|
||||
}
|
||||
|
||||
final reloaded = await sqliteTasks.findById(
|
||||
ownerId: ownerId,
|
||||
taskId: backlogTask.id,
|
||||
);
|
||||
expect(reloaded.isRight, isTrue);
|
||||
final persistedBacklog = reloaded.right;
|
||||
expect(persistedBacklog, isNotNull);
|
||||
expect(persistedBacklog!.scheduledStart, scheduledBacklog.scheduledStart);
|
||||
expect(persistedBacklog.scheduledEnd, scheduledBacklog.scheduledEnd);
|
||||
expect(persistedBacklog.status, core.TaskStatus.planned);
|
||||
});
|
||||
}
|
||||
|
||||
core.Task _taskById(Iterable<core.Task> tasks, String id) {
|
||||
return tasks.singleWhere((task) => task.id == id);
|
||||
}
|
||||
20
packages/scheduler_integration_tests/pubspec.yaml
Normal file
20
packages/scheduler_integration_tests/pubspec.yaml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
name: scheduler_integration_tests
|
||||
description: End-to-end scheduler integration tests.
|
||||
version: 0.1.0
|
||||
publish_to: none
|
||||
resolution: workspace
|
||||
|
||||
environment:
|
||||
sdk: '>=3.6.0 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
drift: ^2.20.0
|
||||
scheduler_core: any
|
||||
scheduler_notifications: any
|
||||
scheduler_persistence: any
|
||||
scheduler_persistence_memory: any
|
||||
scheduler_persistence_sqlite: any
|
||||
|
||||
dev_dependencies:
|
||||
lints: ^5.0.0
|
||||
test: ^1.25.0
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import '../integration/scheduler_flow_test.dart' as scheduler_flow_test;
|
||||
|
||||
void main() {
|
||||
scheduler_flow_test.main();
|
||||
}
|
||||
1
packages/scheduler_notifications/analysis_options.yaml
Normal file
1
packages/scheduler_notifications/analysis_options.yaml
Normal file
|
|
@ -0,0 +1 @@
|
|||
include: package:lints/recommended.yaml
|
||||
4
packages/scheduler_notifications/lib/notifications.dart
Normal file
4
packages/scheduler_notifications/lib/notifications.dart
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/// Notification contracts for scheduler reminder delivery.
|
||||
library;
|
||||
|
||||
export 'src/notification_adapter.dart';
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
/// Package-name entry point for scheduler notification contracts.
|
||||
library;
|
||||
|
||||
export 'notifications.dart';
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
import 'dart:async';
|
||||
|
||||
/// Request to schedule one notification through a platform adapter.
|
||||
sealed class NotificationRequest {
|
||||
/// Creates a notification request.
|
||||
factory NotificationRequest({
|
||||
required String id,
|
||||
required String title,
|
||||
required String body,
|
||||
required DateTime scheduledDateTimeUtc,
|
||||
String payloadJson = '{}',
|
||||
}) {
|
||||
return _NotificationRequest(
|
||||
id: _requiredTrimmed(id, 'id'),
|
||||
title: _requiredTrimmed(title, 'title'),
|
||||
body: body,
|
||||
scheduledDateTimeUtc: scheduledDateTimeUtc.toUtc(),
|
||||
payloadJson: payloadJson,
|
||||
);
|
||||
}
|
||||
|
||||
const NotificationRequest._({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.scheduledDateTimeUtc,
|
||||
required this.payloadJson,
|
||||
});
|
||||
|
||||
/// Stable notification id used for scheduling and cancellation.
|
||||
final String id;
|
||||
|
||||
/// User-visible notification title.
|
||||
final String title;
|
||||
|
||||
/// User-visible notification body.
|
||||
final String body;
|
||||
|
||||
/// UTC instant when the notification should be delivered.
|
||||
final DateTime scheduledDateTimeUtc;
|
||||
|
||||
/// Adapter-neutral JSON payload string.
|
||||
final String payloadJson;
|
||||
}
|
||||
|
||||
final class _NotificationRequest extends NotificationRequest {
|
||||
const _NotificationRequest({
|
||||
required super.id,
|
||||
required super.title,
|
||||
required super.body,
|
||||
required super.scheduledDateTimeUtc,
|
||||
required super.payloadJson,
|
||||
}) : super._();
|
||||
}
|
||||
|
||||
/// User feedback category emitted by a notification adapter.
|
||||
enum NotificationFeedbackType {
|
||||
/// The notification was opened or clicked.
|
||||
clicked,
|
||||
|
||||
/// The notification was dismissed.
|
||||
dismissed,
|
||||
|
||||
/// A notification action was selected.
|
||||
action,
|
||||
}
|
||||
|
||||
/// Feedback event emitted by a notification adapter.
|
||||
final class NotificationFeedback {
|
||||
/// Creates a notification feedback event.
|
||||
NotificationFeedback({
|
||||
required String id,
|
||||
required this.type,
|
||||
this.actionId,
|
||||
this.payloadJson = '{}',
|
||||
}) : id = _requiredTrimmed(id, 'id');
|
||||
|
||||
/// Stable notification id associated with the feedback.
|
||||
final String id;
|
||||
|
||||
/// Type of user feedback.
|
||||
final NotificationFeedbackType type;
|
||||
|
||||
/// Optional action id for [NotificationFeedbackType.action].
|
||||
final String? actionId;
|
||||
|
||||
/// Adapter-neutral JSON payload string.
|
||||
final String payloadJson;
|
||||
}
|
||||
|
||||
/// Platform-neutral notification adapter boundary.
|
||||
abstract interface class NotificationAdapter {
|
||||
/// Schedule [request] for future delivery.
|
||||
Future<void> schedule(NotificationRequest request);
|
||||
|
||||
/// Cancel a pending notification by stable [id].
|
||||
Future<void> cancel(String id);
|
||||
|
||||
/// Stream of user feedback events from delivered notifications.
|
||||
Stream<NotificationFeedback> get feedback;
|
||||
}
|
||||
|
||||
/// Test fake for notification adapter behavior.
|
||||
final class FakeNotificationAdapter implements NotificationAdapter {
|
||||
final StreamController<NotificationFeedback> _feedbackController =
|
||||
StreamController<NotificationFeedback>.broadcast();
|
||||
final Map<String, NotificationRequest> _scheduled =
|
||||
<String, NotificationRequest>{};
|
||||
final List<String> _cancelledIds = <String>[];
|
||||
|
||||
/// Snapshot of currently scheduled requests by id.
|
||||
Map<String, NotificationRequest> get scheduledRequests =>
|
||||
Map<String, NotificationRequest>.unmodifiable(_scheduled);
|
||||
|
||||
/// Snapshot of cancellation ids in call order.
|
||||
List<String> get cancelledIds => List<String>.unmodifiable(_cancelledIds);
|
||||
|
||||
@override
|
||||
Stream<NotificationFeedback> get feedback => _feedbackController.stream;
|
||||
|
||||
@override
|
||||
Future<void> schedule(NotificationRequest request) async {
|
||||
_scheduled[request.id] = request;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cancel(String id) async {
|
||||
final trimmed = _requiredTrimmed(id, 'id');
|
||||
_scheduled.remove(trimmed);
|
||||
_cancelledIds.add(trimmed);
|
||||
}
|
||||
|
||||
/// Emit [event] to feedback listeners.
|
||||
void emitFeedback(NotificationFeedback event) {
|
||||
_feedbackController.add(event);
|
||||
}
|
||||
|
||||
/// Release stream resources held by this fake.
|
||||
Future<void> close() {
|
||||
return _feedbackController.close();
|
||||
}
|
||||
}
|
||||
|
||||
String _requiredTrimmed(String value, String name) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
throw ArgumentError.value(value, name, 'Value must not be blank.');
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
12
packages/scheduler_notifications/pubspec.yaml
Normal file
12
packages/scheduler_notifications/pubspec.yaml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
name: scheduler_notifications
|
||||
description: Notification adapter contracts for the ADHD scheduler.
|
||||
version: 0.1.0
|
||||
publish_to: none
|
||||
resolution: workspace
|
||||
|
||||
environment:
|
||||
sdk: '>=3.6.0 <4.0.0'
|
||||
|
||||
dev_dependencies:
|
||||
lints: ^5.0.0
|
||||
test: ^1.25.0
|
||||
60
packages/scheduler_notifications/test/fake_adapter_test.dart
Normal file
60
packages/scheduler_notifications/test/fake_adapter_test.dart
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import 'package:scheduler_notifications/notifications.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'notification_adapter_contract.dart';
|
||||
|
||||
void main() {
|
||||
runNotificationAdapterContractTests('fake', () {
|
||||
final adapter = FakeNotificationAdapter();
|
||||
return NotificationAdapterContractHarness(
|
||||
adapter: adapter,
|
||||
scheduledRequests: () => adapter.scheduledRequests.values.toList(),
|
||||
cancelledIds: () => adapter.cancelledIds,
|
||||
dispose: adapter.close,
|
||||
);
|
||||
});
|
||||
|
||||
test('fake adapter stores schedules and cancellations', () async {
|
||||
final adapter = FakeNotificationAdapter();
|
||||
addTearDown(adapter.close);
|
||||
final request = NotificationRequest(
|
||||
id: ' reminder-1 ',
|
||||
title: 'Start task',
|
||||
body: 'Time to begin.',
|
||||
scheduledDateTimeUtc: DateTime.utc(2026, 1, 1, 9),
|
||||
payloadJson: '{"taskId":"task-1"}',
|
||||
);
|
||||
|
||||
await adapter.schedule(request);
|
||||
expect(adapter.scheduledRequests.keys, ['reminder-1']);
|
||||
expect(
|
||||
adapter.scheduledRequests['reminder-1']!.scheduledDateTimeUtc.isUtc,
|
||||
isTrue,
|
||||
);
|
||||
|
||||
await adapter.cancel(' reminder-1 ');
|
||||
expect(adapter.scheduledRequests, isEmpty);
|
||||
expect(adapter.cancelledIds, ['reminder-1']);
|
||||
});
|
||||
|
||||
test('fake adapter emits feedback events', () async {
|
||||
final adapter = FakeNotificationAdapter();
|
||||
addTearDown(adapter.close);
|
||||
final event = NotificationFeedback(
|
||||
id: 'reminder-1',
|
||||
type: NotificationFeedbackType.clicked,
|
||||
payloadJson: '{"taskId":"task-1"}',
|
||||
);
|
||||
|
||||
expectLater(
|
||||
adapter.feedback,
|
||||
emits(predicate<NotificationFeedback>((feedback) {
|
||||
return feedback.id == 'reminder-1' &&
|
||||
feedback.type == NotificationFeedbackType.clicked &&
|
||||
feedback.payloadJson == '{"taskId":"task-1"}';
|
||||
})),
|
||||
);
|
||||
|
||||
adapter.emitFeedback(event);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import 'package:scheduler_notifications/notifications.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// Test harness for [NotificationAdapter] contract checks.
|
||||
final class NotificationAdapterContractHarness {
|
||||
/// Creates a harness around one adapter instance.
|
||||
const NotificationAdapterContractHarness({
|
||||
required this.adapter,
|
||||
required this.scheduledRequests,
|
||||
required this.cancelledIds,
|
||||
this.dispose,
|
||||
});
|
||||
|
||||
/// Adapter under test.
|
||||
final NotificationAdapter adapter;
|
||||
|
||||
/// Snapshot of requests delivered through [adapter].
|
||||
final List<NotificationRequest> Function() scheduledRequests;
|
||||
|
||||
/// Snapshot of ids cancelled through [adapter].
|
||||
final List<String> Function() cancelledIds;
|
||||
|
||||
/// Optional cleanup callback.
|
||||
final Future<void> Function()? dispose;
|
||||
}
|
||||
|
||||
/// Runs shared notification adapter behavior checks.
|
||||
void runNotificationAdapterContractTests(
|
||||
String name,
|
||||
NotificationAdapterContractHarness Function() createHarness,
|
||||
) {
|
||||
group('$name notification adapter contract', () {
|
||||
test('schedules normalized notification requests', () async {
|
||||
final harness = createHarness();
|
||||
final dispose = harness.dispose;
|
||||
if (dispose != null) {
|
||||
addTearDown(dispose);
|
||||
}
|
||||
|
||||
await harness.adapter.schedule(_request(' reminder-1 '));
|
||||
|
||||
expect(harness.scheduledRequests().map((request) => request.id), [
|
||||
'reminder-1',
|
||||
]);
|
||||
expect(harness.scheduledRequests().single.scheduledDateTimeUtc.isUtc,
|
||||
isTrue);
|
||||
});
|
||||
|
||||
test('cancels normalized ids', () async {
|
||||
final harness = createHarness();
|
||||
final dispose = harness.dispose;
|
||||
if (dispose != null) {
|
||||
addTearDown(dispose);
|
||||
}
|
||||
|
||||
await harness.adapter.cancel(' reminder-1 ');
|
||||
|
||||
expect(harness.cancelledIds(), ['reminder-1']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
NotificationRequest _request(String id) {
|
||||
return NotificationRequest(
|
||||
id: id,
|
||||
title: 'Start task',
|
||||
body: 'Time to begin.',
|
||||
scheduledDateTimeUtc: DateTime.utc(2026, 1, 1, 9),
|
||||
payloadJson: '{"taskId":"task-1"}',
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
include: package:lints/recommended.yaml
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
/// Desktop notification adapter for scheduler reminder delivery.
|
||||
library;
|
||||
|
||||
export 'src/desktop_notification_adapter.dart';
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
/// Package-name entry point for desktop notification adapters.
|
||||
library;
|
||||
|
||||
export 'desktop_notifications.dart';
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export 'desktop_notification_adapter_stub.dart'
|
||||
if (dart.library.io) 'desktop_notification_adapter_io.dart';
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:scheduler_notifications/notifications.dart';
|
||||
|
||||
/// Supported desktop platform categories.
|
||||
enum DesktopNotificationPlatform {
|
||||
/// Linux desktop.
|
||||
linux,
|
||||
|
||||
/// macOS desktop.
|
||||
macos,
|
||||
|
||||
/// Windows desktop.
|
||||
windows,
|
||||
|
||||
/// Unsupported or unknown platform.
|
||||
unsupported,
|
||||
}
|
||||
|
||||
/// Logging callback used by fallback notification backends.
|
||||
typedef DesktopNotificationLogSink = void Function(String line);
|
||||
|
||||
/// Process runner used by command-backed notification backends.
|
||||
typedef DesktopProcessRunner = Future<ProcessResult> Function(
|
||||
String executable,
|
||||
List<String> arguments,
|
||||
);
|
||||
|
||||
/// Platform-neutral backend used by [DesktopNotificationAdapter].
|
||||
abstract interface class DesktopNotificationBackend {
|
||||
/// Whether this backend has native notification support.
|
||||
bool get isSupported;
|
||||
|
||||
/// Deliver [request].
|
||||
Future<void> deliver(NotificationRequest request);
|
||||
|
||||
/// Cancel a pending notification by [id], when supported.
|
||||
Future<void> cancel(String id);
|
||||
}
|
||||
|
||||
/// Notification adapter that delegates to a desktop backend.
|
||||
final class DesktopNotificationAdapter implements NotificationAdapter {
|
||||
/// Creates an adapter backed by [backend].
|
||||
DesktopNotificationAdapter({required DesktopNotificationBackend backend})
|
||||
: _backend = backend;
|
||||
|
||||
final DesktopNotificationBackend _backend;
|
||||
|
||||
/// Creates the best available desktop adapter for the current platform.
|
||||
factory DesktopNotificationAdapter.defaultInstance({
|
||||
DesktopNotificationPlatform? platform,
|
||||
DesktopProcessRunner? processRunner,
|
||||
DesktopNotificationLogSink? logSink,
|
||||
}) {
|
||||
return DesktopNotificationAdapter(
|
||||
backend: createDefaultDesktopNotificationBackend(
|
||||
platform: platform,
|
||||
processRunner: processRunner,
|
||||
logSink: logSink,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<NotificationFeedback> get feedback => const Stream.empty();
|
||||
|
||||
@override
|
||||
Future<void> schedule(NotificationRequest request) {
|
||||
return _backend.deliver(request);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cancel(String id) {
|
||||
return _backend.cancel(id.trim());
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the best default desktop notification backend.
|
||||
DesktopNotificationBackend createDefaultDesktopNotificationBackend({
|
||||
DesktopNotificationPlatform? platform,
|
||||
DesktopProcessRunner? processRunner,
|
||||
DesktopNotificationLogSink? logSink,
|
||||
}) {
|
||||
final resolvedPlatform = platform ?? _currentPlatform();
|
||||
final runner = processRunner ?? Process.run;
|
||||
final fallback = StdoutNotificationBackend(
|
||||
logSink: logSink ?? stdout.writeln,
|
||||
platform: resolvedPlatform,
|
||||
);
|
||||
|
||||
return switch (resolvedPlatform) {
|
||||
DesktopNotificationPlatform.linux => LinuxNotifySendBackend(
|
||||
processRunner: runner,
|
||||
fallback: fallback,
|
||||
),
|
||||
DesktopNotificationPlatform.macos => MacOsNotificationBackend(
|
||||
processRunner: runner,
|
||||
fallback: fallback,
|
||||
),
|
||||
DesktopNotificationPlatform.windows => fallback,
|
||||
DesktopNotificationPlatform.unsupported => fallback,
|
||||
};
|
||||
}
|
||||
|
||||
/// Linux backend using `notify-send`.
|
||||
final class LinuxNotifySendBackend implements DesktopNotificationBackend {
|
||||
/// Creates a Linux command-backed backend.
|
||||
LinuxNotifySendBackend({
|
||||
required DesktopProcessRunner processRunner,
|
||||
required DesktopNotificationBackend fallback,
|
||||
}) : _processRunner = processRunner,
|
||||
_fallback = fallback;
|
||||
|
||||
final DesktopProcessRunner _processRunner;
|
||||
final DesktopNotificationBackend _fallback;
|
||||
|
||||
@override
|
||||
bool get isSupported => true;
|
||||
|
||||
@override
|
||||
Future<void> deliver(NotificationRequest request) async {
|
||||
await _runOrFallback(
|
||||
request,
|
||||
() => _processRunner('notify-send', <String>[
|
||||
'--app-name=ADHD Scheduler',
|
||||
request.title,
|
||||
request.body,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cancel(String id) async {
|
||||
await _fallback.cancel(id);
|
||||
}
|
||||
|
||||
Future<void> _runOrFallback(
|
||||
NotificationRequest request,
|
||||
Future<ProcessResult> Function() run,
|
||||
) async {
|
||||
try {
|
||||
final result = await run();
|
||||
if (result.exitCode != 0) {
|
||||
await _fallback.deliver(request);
|
||||
}
|
||||
} on ProcessException {
|
||||
await _fallback.deliver(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS backend using `osascript`.
|
||||
final class MacOsNotificationBackend implements DesktopNotificationBackend {
|
||||
/// Creates a macOS command-backed backend.
|
||||
MacOsNotificationBackend({
|
||||
required DesktopProcessRunner processRunner,
|
||||
required DesktopNotificationBackend fallback,
|
||||
}) : _processRunner = processRunner,
|
||||
_fallback = fallback;
|
||||
|
||||
final DesktopProcessRunner _processRunner;
|
||||
final DesktopNotificationBackend _fallback;
|
||||
|
||||
@override
|
||||
bool get isSupported => true;
|
||||
|
||||
@override
|
||||
Future<void> deliver(NotificationRequest request) async {
|
||||
final script = 'display notification ${_appleScriptString(request.body)} '
|
||||
'with title ${_appleScriptString(request.title)}';
|
||||
try {
|
||||
final result = await _processRunner('osascript', <String>['-e', script]);
|
||||
if (result.exitCode != 0) {
|
||||
await _fallback.deliver(request);
|
||||
}
|
||||
} on ProcessException {
|
||||
await _fallback.deliver(request);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cancel(String id) async {
|
||||
await _fallback.cancel(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback backend that writes notification requests to stdout/logs.
|
||||
final class StdoutNotificationBackend implements DesktopNotificationBackend {
|
||||
/// Creates a stdout fallback backend.
|
||||
StdoutNotificationBackend({
|
||||
required DesktopNotificationLogSink logSink,
|
||||
required this.platform,
|
||||
}) : _logSink = logSink;
|
||||
|
||||
final DesktopNotificationLogSink _logSink;
|
||||
|
||||
/// Platform this fallback represents.
|
||||
final DesktopNotificationPlatform platform;
|
||||
|
||||
@override
|
||||
bool get isSupported => false;
|
||||
|
||||
@override
|
||||
Future<void> deliver(NotificationRequest request) async {
|
||||
_logSink(
|
||||
'notification[$platform] ${request.id}: ${request.title} - '
|
||||
'${request.body}',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cancel(String id) async {
|
||||
_logSink('notification[$platform] cancel: ${id.trim()}');
|
||||
}
|
||||
}
|
||||
|
||||
DesktopNotificationPlatform _currentPlatform() {
|
||||
if (Platform.isLinux) return DesktopNotificationPlatform.linux;
|
||||
if (Platform.isMacOS) return DesktopNotificationPlatform.macos;
|
||||
if (Platform.isWindows) return DesktopNotificationPlatform.windows;
|
||||
return DesktopNotificationPlatform.unsupported;
|
||||
}
|
||||
|
||||
String _appleScriptString(String value) {
|
||||
return jsonEncode(value);
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:scheduler_notifications/notifications.dart';
|
||||
|
||||
/// Supported desktop platform categories.
|
||||
enum DesktopNotificationPlatform {
|
||||
/// Linux desktop.
|
||||
linux,
|
||||
|
||||
/// macOS desktop.
|
||||
macos,
|
||||
|
||||
/// Windows desktop.
|
||||
windows,
|
||||
|
||||
/// Unsupported or unknown platform.
|
||||
unsupported,
|
||||
}
|
||||
|
||||
/// Logging callback used by fallback notification backends.
|
||||
typedef DesktopNotificationLogSink = void Function(String line);
|
||||
|
||||
/// Platform-neutral backend used by [DesktopNotificationAdapter].
|
||||
abstract interface class DesktopNotificationBackend {
|
||||
/// Whether this backend has native notification support.
|
||||
bool get isSupported;
|
||||
|
||||
/// Deliver [request].
|
||||
Future<void> deliver(NotificationRequest request);
|
||||
|
||||
/// Cancel a pending notification by [id], when supported.
|
||||
Future<void> cancel(String id);
|
||||
}
|
||||
|
||||
/// Notification adapter that delegates to a desktop backend.
|
||||
final class DesktopNotificationAdapter implements NotificationAdapter {
|
||||
/// Creates an adapter backed by [backend].
|
||||
DesktopNotificationAdapter({required DesktopNotificationBackend backend})
|
||||
: _backend = backend;
|
||||
|
||||
final DesktopNotificationBackend _backend;
|
||||
|
||||
/// Creates the best available desktop adapter for the current platform.
|
||||
factory DesktopNotificationAdapter.defaultInstance({
|
||||
DesktopNotificationPlatform? platform,
|
||||
Object? processRunner,
|
||||
DesktopNotificationLogSink? logSink,
|
||||
}) {
|
||||
return DesktopNotificationAdapter(
|
||||
backend: createDefaultDesktopNotificationBackend(
|
||||
platform: platform,
|
||||
processRunner: processRunner,
|
||||
logSink: logSink,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<NotificationFeedback> get feedback => const Stream.empty();
|
||||
|
||||
@override
|
||||
Future<void> schedule(NotificationRequest request) {
|
||||
return _backend.deliver(request);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cancel(String id) {
|
||||
return _backend.cancel(id.trim());
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the best default desktop notification backend.
|
||||
DesktopNotificationBackend createDefaultDesktopNotificationBackend({
|
||||
DesktopNotificationPlatform? platform,
|
||||
Object? processRunner,
|
||||
DesktopNotificationLogSink? logSink,
|
||||
}) {
|
||||
return StdoutNotificationBackend(
|
||||
logSink: logSink ?? _defaultLogSink,
|
||||
platform: platform ?? DesktopNotificationPlatform.unsupported,
|
||||
);
|
||||
}
|
||||
|
||||
/// Fallback backend that writes notification requests to stdout/logs.
|
||||
final class StdoutNotificationBackend implements DesktopNotificationBackend {
|
||||
/// Creates a stdout fallback backend.
|
||||
StdoutNotificationBackend({
|
||||
required DesktopNotificationLogSink logSink,
|
||||
required this.platform,
|
||||
}) : _logSink = logSink;
|
||||
|
||||
final DesktopNotificationLogSink _logSink;
|
||||
|
||||
/// Platform this fallback represents.
|
||||
final DesktopNotificationPlatform platform;
|
||||
|
||||
@override
|
||||
bool get isSupported => false;
|
||||
|
||||
@override
|
||||
Future<void> deliver(NotificationRequest request) async {
|
||||
_logSink(
|
||||
'notification[$platform] ${request.id}: ${request.title} - '
|
||||
'${request.body}',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cancel(String id) async {
|
||||
_logSink('notification[$platform] cancel: ${id.trim()}');
|
||||
}
|
||||
}
|
||||
|
||||
void _defaultLogSink(String line) {
|
||||
// ignore: avoid_print
|
||||
print(line);
|
||||
}
|
||||
15
packages/scheduler_notifications_desktop/pubspec.yaml
Normal file
15
packages/scheduler_notifications_desktop/pubspec.yaml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
name: scheduler_notifications_desktop
|
||||
description: Desktop notification adapter for the ADHD scheduler.
|
||||
version: 0.1.0
|
||||
publish_to: none
|
||||
resolution: workspace
|
||||
|
||||
environment:
|
||||
sdk: '>=3.6.0 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
scheduler_notifications: any
|
||||
|
||||
dev_dependencies:
|
||||
lints: ^5.0.0
|
||||
test: ^1.25.0
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:scheduler_notifications/notifications.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../../scheduler_notifications/test/notification_adapter_contract.dart';
|
||||
|
||||
// ignore: avoid_relative_lib_imports
|
||||
import '../lib/desktop_notifications.dart';
|
||||
|
||||
void main() {
|
||||
runNotificationAdapterContractTests('desktop', () {
|
||||
final backend = _RecordingBackend();
|
||||
return NotificationAdapterContractHarness(
|
||||
adapter: DesktopNotificationAdapter(backend: backend),
|
||||
scheduledRequests: () => backend.delivered,
|
||||
cancelledIds: () => backend.cancelled,
|
||||
);
|
||||
});
|
||||
|
||||
test('desktop adapter delegates schedule and cancel to backend', () async {
|
||||
final backend = _RecordingBackend();
|
||||
final adapter = DesktopNotificationAdapter(backend: backend);
|
||||
final request = _request('reminder-1');
|
||||
|
||||
await adapter.schedule(request);
|
||||
await adapter.cancel(' reminder-1 ');
|
||||
|
||||
expect(backend.delivered, [request]);
|
||||
expect(backend.cancelled, ['reminder-1']);
|
||||
expect(adapter, isA<NotificationAdapter>());
|
||||
});
|
||||
|
||||
test('linux backend invokes notify-send', () async {
|
||||
final calls = <_ProcessCall>[];
|
||||
final logs = <String>[];
|
||||
final backend = createDefaultDesktopNotificationBackend(
|
||||
platform: DesktopNotificationPlatform.linux,
|
||||
processRunner: (executable, arguments) async {
|
||||
calls.add(_ProcessCall(executable, arguments));
|
||||
return ProcessResult(1, 0, '', '');
|
||||
},
|
||||
logSink: logs.add,
|
||||
);
|
||||
|
||||
await backend.deliver(_request('reminder-1'));
|
||||
|
||||
expect(backend.isSupported, isTrue);
|
||||
expect(calls.single.executable, 'notify-send');
|
||||
expect(calls.single.arguments, contains('Start task'));
|
||||
expect(logs, isEmpty);
|
||||
});
|
||||
|
||||
test('macOS backend invokes osascript', () async {
|
||||
final calls = <_ProcessCall>[];
|
||||
final backend = createDefaultDesktopNotificationBackend(
|
||||
platform: DesktopNotificationPlatform.macos,
|
||||
processRunner: (executable, arguments) async {
|
||||
calls.add(_ProcessCall(executable, arguments));
|
||||
return ProcessResult(1, 0, '', '');
|
||||
},
|
||||
);
|
||||
|
||||
await backend.deliver(_request('reminder-1'));
|
||||
|
||||
expect(backend.isSupported, isTrue);
|
||||
expect(calls.single.executable, 'osascript');
|
||||
expect(calls.single.arguments, contains('-e'));
|
||||
});
|
||||
|
||||
test('unsupported and Windows platforms fall back to log output', () async {
|
||||
final logs = <String>[];
|
||||
final windows = createDefaultDesktopNotificationBackend(
|
||||
platform: DesktopNotificationPlatform.windows,
|
||||
logSink: logs.add,
|
||||
);
|
||||
final unsupported = createDefaultDesktopNotificationBackend(
|
||||
platform: DesktopNotificationPlatform.unsupported,
|
||||
logSink: logs.add,
|
||||
);
|
||||
|
||||
await windows.deliver(_request('windows-reminder'));
|
||||
await unsupported.cancel(' unsupported-reminder ');
|
||||
|
||||
expect(windows.isSupported, isFalse);
|
||||
expect(unsupported.isSupported, isFalse);
|
||||
expect(logs.first, contains('windows-reminder'));
|
||||
expect(logs.last, contains('unsupported-reminder'));
|
||||
});
|
||||
|
||||
test('fake adapter can stand in for scheduling workflows', () async {
|
||||
final adapter = FakeNotificationAdapter();
|
||||
addTearDown(adapter.close);
|
||||
await _scheduleAndCancelReminder(adapter, _request('reminder-1'));
|
||||
|
||||
expect(adapter.scheduledRequests, isEmpty);
|
||||
expect(adapter.cancelledIds, ['reminder-1']);
|
||||
});
|
||||
|
||||
test('default desktop adapter can be created on supported test platforms',
|
||||
() {
|
||||
if (!Platform.isLinux && !Platform.isMacOS && !Platform.isWindows) {
|
||||
markTestSkipped(
|
||||
'Desktop notifications are unsupported on this platform.');
|
||||
}
|
||||
|
||||
expect(DesktopNotificationAdapter.defaultInstance(),
|
||||
isA<NotificationAdapter>());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _scheduleAndCancelReminder(
|
||||
NotificationAdapter adapter,
|
||||
NotificationRequest request,
|
||||
) async {
|
||||
await adapter.schedule(request);
|
||||
await adapter.cancel(request.id);
|
||||
}
|
||||
|
||||
NotificationRequest _request(String id) {
|
||||
return NotificationRequest(
|
||||
id: id,
|
||||
title: 'Start task',
|
||||
body: 'Time to begin.',
|
||||
scheduledDateTimeUtc: DateTime.utc(2026, 1, 1, 9),
|
||||
);
|
||||
}
|
||||
|
||||
final class _RecordingBackend implements DesktopNotificationBackend {
|
||||
final List<NotificationRequest> delivered = <NotificationRequest>[];
|
||||
final List<String> cancelled = <String>[];
|
||||
|
||||
@override
|
||||
bool get isSupported => true;
|
||||
|
||||
@override
|
||||
Future<void> deliver(NotificationRequest request) async {
|
||||
delivered.add(request);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cancel(String id) async {
|
||||
cancelled.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
final class _ProcessCall {
|
||||
const _ProcessCall(this.executable, this.arguments);
|
||||
|
||||
final String executable;
|
||||
final List<String> arguments;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue