feat: split scheduler into workspace packages

This commit is contained in:
Ashley Venn 2026-06-26 19:37:40 -07:00
parent 44c3ca4c93
commit 60ca067236
120 changed files with 12543 additions and 48 deletions

View file

@ -0,0 +1 @@
include: package:lints/recommended.yaml

View 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];
}

View file

@ -0,0 +1,4 @@
/// Encrypted SQLite backup helpers.
library;
export 'src/encrypted_backup.dart';

View file

@ -0,0 +1,4 @@
/// Package-name entry point for scheduler backup helpers.
library;
export 'backup.dart';

View 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';
}

View 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

View 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>()),
);
});
}

View file

@ -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';

View file

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

View file

@ -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';

View file

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

View file

@ -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';

View file

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

View file

@ -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';

View 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;
}

View file

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

View file

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

View 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

View file

@ -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',

View file

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

View file

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

View file

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

View file

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

View file

@ -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);

View file

@ -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);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
include: package:lints/recommended.yaml

View file

@ -0,0 +1,4 @@
/// Readable export contracts and controller for scheduler data.
library;
export 'src/export_controller.dart';

View file

@ -0,0 +1,4 @@
/// Package-name entry point for scheduler export contracts.
library;
export 'export.dart';

View 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;
}

View 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

View 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 {}
}

View file

@ -0,0 +1 @@
include: package:lints/recommended.yaml

View 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];
}

View file

@ -0,0 +1,4 @@
/// JSON and CSV readable export writers.
library;
export 'src/json_csv_writers.dart';

View file

@ -0,0 +1,4 @@
/// Package-name entry point for scheduler readable export writers.
library;
export 'export_json.dart';

View 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;
}

View 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

View 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),
);
}

View file

@ -0,0 +1 @@
include: package:lints/recommended.yaml

View file

@ -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);
}

View 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

View file

@ -0,0 +1,5 @@
import '../integration/scheduler_flow_test.dart' as scheduler_flow_test;
void main() {
scheduler_flow_test.main();
}

View file

@ -0,0 +1 @@
include: package:lints/recommended.yaml

View file

@ -0,0 +1,4 @@
/// Notification contracts for scheduler reminder delivery.
library;
export 'src/notification_adapter.dart';

View file

@ -0,0 +1,4 @@
/// Package-name entry point for scheduler notification contracts.
library;
export 'notifications.dart';

View file

@ -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;
}

View 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

View 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);
});
}

View file

@ -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"}',
);
}

View file

@ -0,0 +1 @@
include: package:lints/recommended.yaml

View file

@ -0,0 +1,4 @@
/// Desktop notification adapter for scheduler reminder delivery.
library;
export 'src/desktop_notification_adapter.dart';

View file

@ -0,0 +1,4 @@
/// Package-name entry point for desktop notification adapters.
library;
export 'desktop_notifications.dart';

View file

@ -0,0 +1,2 @@
export 'desktop_notification_adapter_stub.dart'
if (dart.library.io) 'desktop_notification_adapter_io.dart';

View file

@ -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);
}

View file

@ -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);
}

View 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

View file

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