Halkyon's shared packages

A pub workspace of the Dart packages Halkyon's apps share: halkyon_ui,
Halkyon's look (the theme, fonts, layout and shared widgets, with texts
in English and German), halkyon_core's low-level primitives and
halkyon_interop's cross-app contracts. Dart 3.13 with LeanCode's lints,
configured once at the root; apps take a package by git, pinned to a
signed commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-18 21:46:55 +02:00
commit 80fa7b9628
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
12 changed files with 6762 additions and 0 deletions

31
packages/halkyon_interop/.gitignore vendored Normal file
View file

@ -0,0 +1,31 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
/pubspec.lock
**/doc/api/
.dart_tool/
.flutter-plugins-dependencies
/build/
/coverage/

View file

@ -0,0 +1,10 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "cc0734ac716fbb8b90f3f9db8020958b1553afa7"
channel: "stable"
project_type: package

View file

@ -0,0 +1,3 @@
## 0.0.1
* TODO: Describe initial release.

View file

@ -0,0 +1 @@
TODO: Add your license here.

View file

@ -0,0 +1,39 @@
<!--
This README describes the package. If you publish this package to pub.dev,
this README's contents appear on the landing page for your package.
For information about how to write a good package README, see the guide for
[writing package pages](https://dart.dev/tools/pub/writing-package-pages).
For general information about developing packages, see the Dart guide for
[creating packages](https://dart.dev/guides/libraries/create-packages)
and the Flutter guide for
[developing packages and plugins](https://flutter.dev/to/develop-packages).
-->
TODO: Put a short description of the package here that helps potential users
know whether this package might be useful for them.
## Features
TODO: List what your package can do. Maybe include images, gifs, or videos.
## Getting started
TODO: List prerequisites and provide or point to information on how to
start using the package.
## Usage
TODO: Include short and useful examples for package users. Add longer examples
to `/example` folder.
```dart
const like = 'sample';
```
## Additional information
TODO: Tell users more about the package: where to find more information, how to
contribute to the package, how to file issues, what response they can expect
from the package authors, and more.

View file

@ -0,0 +1,2 @@
# The workspace's lint rules (see ../../analysis_options.yaml).
include: ../../analysis_options.yaml

View file

@ -0,0 +1,13 @@
/// Cross-app interoperability contracts for the Halkyon ecosystem.
///
/// This package is pure Dart and defines:
/// - `TaskShape`: a minimal type for representing tasks across apps
/// (e.g., a Halkyon Learn review session surfaced in Halkyon ToDo).
/// - `HalkyonEvent`: a sealed class hierarchy for cross-app events.
/// - `EventPublisher`: an interface for emitting events to other apps,
/// with `DormantPublisher` as a no-op default for apps running standalone.
library;
export 'src/event_publisher.dart';
export 'src/events.dart';
export 'src/task_shape.dart';

View file

@ -0,0 +1,24 @@
import 'package:halkyon_interop/src/events.dart';
/// Publishes [HalkyonEvent]s to other Halkyon apps installed alongside this one.
///
/// The actual transport (platform channels, intents, App Group containers,
/// etc.) is implementation-specific and lives in the app's composition root,
/// not in this package.
abstract interface class EventPublisher() {
/// Publish an event. Implementations should be non-blocking and best-effort:
/// failing to deliver to peer apps must not fail the originating action.
Future<void> publish(HalkyonEvent event);
}
/// A no-op [EventPublisher] for apps running in isolation.
///
/// When a Halkyon app starts and detects no peer apps to broadcast to (or
/// when the user has disabled cross-app communication), inject this. All
/// `publish` calls are silently dropped.
class const DormantPublisher() implements EventPublisher {
@override
Future<void> publish(HalkyonEvent event) async {
// Intentionally empty. Events are dropped when there are no peers.
}
}

View file

@ -0,0 +1,43 @@
import 'package:meta/meta.dart';
/// Base type for events published across Halkyon apps.
///
/// Sealed: all subtypes must be declared in this file. Adding a new event
/// type is a deliberate cross-app contract change, not something that should
/// happen incidentally inside a feature.
@immutable
sealed class const HalkyonEvent({
/// When the event occurred. Use the app's injected `Clock` to set this,
/// not `DateTime.now()` directly, so tests can control time.
required final DateTime occurredAt,
});
/// Emitted when a Halkyon Learn review becomes due for the user.
///
/// Consuming apps (e.g., Halkyon ToDo) can subscribe to surface this as a
/// task or notification.
final class const ReviewDueEvent({
required super.occurredAt,
/// Identifier of the studyset whose cards are due.
required final String studysetId,
/// Number of cards in this studyset currently due for review.
required final int dueCount,
}) extends HalkyonEvent {
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ReviewDueEvent &&
runtimeType == other.runtimeType &&
occurredAt == other.occurredAt &&
studysetId == other.studysetId &&
dueCount == other.dueCount;
@override
int get hashCode => Object.hash(occurredAt, studysetId, dueCount);
@override
String toString() =>
'ReviewDueEvent(studyset=$studysetId, due=$dueCount, at=$occurredAt)';
}

View file

@ -0,0 +1,53 @@
import 'package:meta/meta.dart';
/// A minimal cross-app representation of a task.
///
/// This is the contract for surfacing one app's actionable items in another
/// app's task views. Halkyon Learn might surface "review 12 cards in Spanish"
/// as a [TaskShape] that Halkyon ToDo displays alongside native todos.
///
/// Kept deliberately minimal. Apps consuming a [TaskShape] should treat it
/// as opaque metadata; deep-linking back to the originating app is via
/// [deepLink], not by interpreting [sourceAppId] or [externalId].
@immutable
class const TaskShape({
/// Identifier of the app that owns this task. Conventionally a reverse-DNS
/// string like `eu.halkyon.learn`.
required final String sourceAppId,
/// The originating app's stable identifier for this task. Opaque to
/// consumers; used together with [sourceAppId] for deduplication.
required final String externalId,
/// Human-readable title displayed in the consuming app's task list.
required final String title,
/// A URI the consuming app can open to hand off back to the originating
/// app. Format is app-specific (custom scheme or universal link).
required final Uri deepLink,
/// When the task becomes actionable or overdue. Null means "no due date."
final DateTime? dueAt,
/// Optional secondary line. Keep short — consumers may truncate.
final String? subtitle,
}) {
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TaskShape &&
runtimeType == other.runtimeType &&
sourceAppId == other.sourceAppId &&
externalId == other.externalId &&
title == other.title &&
subtitle == other.subtitle &&
dueAt == other.dueAt &&
deepLink == other.deepLink;
@override
int get hashCode =>
Object.hash(sourceAppId, externalId, title, subtitle, dueAt, deepLink);
@override
String toString() => 'TaskShape($sourceAppId/$externalId: $title)';
}

View file

@ -0,0 +1,14 @@
name: halkyon_interop
description: "Cross-app interoperability types and contracts for the Halkyon ecosystem. Pure Dart, no Flutter."
version: 0.0.1
publish_to: none
resolution: workspace
environment:
sdk: ^3.13.0
dependencies:
meta: ^1.16.0
dev_dependencies:
test: ^1.25.0

View file

@ -0,0 +1,174 @@
import 'package:halkyon_interop/halkyon_interop.dart';
import 'package:test/test.dart';
void main() {
group('TaskShape', () {
final fixedTime = DateTime.utc(2026, 1, 15, 12);
final deepLink = Uri.parse('halkyon-learn://review/spanish-vocab');
TaskShape build({
String sourceAppId = 'eu.halkyon.learn',
String externalId = 'review-12',
String title = 'Review 12 cards',
String? subtitle = 'Spanish vocabulary',
DateTime? dueAt,
Uri? linkOverride,
}) => TaskShape(
sourceAppId: sourceAppId,
externalId: externalId,
title: title,
subtitle: subtitle,
dueAt: dueAt ?? fixedTime,
deepLink: linkOverride ?? deepLink,
);
test('exposes provided fields', () {
final task = build();
expect(task.sourceAppId, 'eu.halkyon.learn');
expect(task.externalId, 'review-12');
expect(task.title, 'Review 12 cards');
expect(task.subtitle, 'Spanish vocabulary');
expect(task.dueAt, fixedTime);
expect(task.deepLink, deepLink);
});
test('allows null for optional fields', () {
final task = TaskShape(
sourceAppId: 'eu.halkyon.learn',
externalId: 'review-12',
title: 'Review',
deepLink: deepLink,
);
expect(task.subtitle, isNull);
expect(task.dueAt, isNull);
});
test('two instances with identical fields are equal', () {
expect(build(), equals(build()));
expect(build().hashCode, equals(build().hashCode));
});
test('differs on any field change', () {
expect(build(), isNot(equals(build(title: 'Different'))));
expect(build(), isNot(equals(build(externalId: 'different-id'))));
expect(build(), isNot(equals(build(sourceAppId: 'eu.halkyon.todo'))));
expect(build(), isNot(equals(build(dueAt: DateTime.utc(2026, 2)))));
expect(
build(),
isNot(equals(build(linkOverride: Uri.parse('halkyon-todo://x')))),
);
});
test('toString includes identifying fields', () {
final str = build().toString();
expect(str, contains('eu.halkyon.learn'));
expect(str, contains('review-12'));
expect(str, contains('Review 12 cards'));
});
});
group('HalkyonEvent / ReviewDueEvent', () {
final fixedTime = DateTime.utc(2026, 1, 15, 12);
test('ReviewDueEvent is a HalkyonEvent', () {
final event = ReviewDueEvent(
occurredAt: fixedTime,
studysetId: 'spanish-vocab',
dueCount: 12,
);
expect(event, isA<HalkyonEvent>());
});
test('exposes provided fields', () {
final event = ReviewDueEvent(
occurredAt: fixedTime,
studysetId: 'spanish-vocab',
dueCount: 12,
);
expect(event.occurredAt, fixedTime);
expect(event.studysetId, 'spanish-vocab');
expect(event.dueCount, 12);
});
test('events with identical fields are equal', () {
final a = ReviewDueEvent(
occurredAt: fixedTime,
studysetId: 'spanish-vocab',
dueCount: 12,
);
final b = ReviewDueEvent(
occurredAt: fixedTime,
studysetId: 'spanish-vocab',
dueCount: 12,
);
expect(a, equals(b));
expect(a.hashCode, equals(b.hashCode));
});
test('differs on field changes', () {
final base = ReviewDueEvent(
occurredAt: fixedTime,
studysetId: 'spanish-vocab',
dueCount: 12,
);
expect(
base,
isNot(
equals(
ReviewDueEvent(
occurredAt: fixedTime,
studysetId: 'spanish-vocab',
dueCount: 13,
),
),
),
);
expect(
base,
isNot(
equals(
ReviewDueEvent(
occurredAt: fixedTime,
studysetId: 'french-vocab',
dueCount: 12,
),
),
),
);
});
test('switch on sealed type is exhaustive', () {
final HalkyonEvent event = ReviewDueEvent(
occurredAt: fixedTime,
studysetId: 'spanish-vocab',
dueCount: 12,
);
// If a new HalkyonEvent subtype is added without updating this switch,
// the analyzer/compiler will flag it. That's the value of `sealed`.
final description = switch (event) {
ReviewDueEvent(:final studysetId, :final dueCount) =>
'$dueCount cards due in $studysetId',
};
expect(description, '12 cards due in spanish-vocab');
});
});
group('DormantPublisher', () {
test('accepts publish calls without throwing', () async {
const publisher = DormantPublisher();
final event = ReviewDueEvent(
occurredAt: DateTime.utc(2026, 1, 15),
studysetId: 'spanish-vocab',
dueCount: 5,
);
await expectLater(publisher.publish(event), completes);
});
test('implements EventPublisher', () {
const publisher = DormantPublisher();
expect(publisher, isA<EventPublisher>());
});
});
}