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:
commit
80fa7b9628
12 changed files with 6762 additions and 0 deletions
31
packages/halkyon_interop/.gitignore
vendored
Normal file
31
packages/halkyon_interop/.gitignore
vendored
Normal 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/
|
||||
10
packages/halkyon_interop/.metadata
Normal file
10
packages/halkyon_interop/.metadata
Normal 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
|
||||
3
packages/halkyon_interop/CHANGELOG.md
Normal file
3
packages/halkyon_interop/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
## 0.0.1
|
||||
|
||||
* TODO: Describe initial release.
|
||||
1
packages/halkyon_interop/LICENSE
Normal file
1
packages/halkyon_interop/LICENSE
Normal file
|
|
@ -0,0 +1 @@
|
|||
TODO: Add your license here.
|
||||
39
packages/halkyon_interop/README.md
Normal file
39
packages/halkyon_interop/README.md
Normal 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.
|
||||
2
packages/halkyon_interop/analysis_options.yaml
Normal file
2
packages/halkyon_interop/analysis_options.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# The workspace's lint rules (see ../../analysis_options.yaml).
|
||||
include: ../../analysis_options.yaml
|
||||
13
packages/halkyon_interop/lib/halkyon_interop.dart
Normal file
13
packages/halkyon_interop/lib/halkyon_interop.dart
Normal 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';
|
||||
24
packages/halkyon_interop/lib/src/event_publisher.dart
Normal file
24
packages/halkyon_interop/lib/src/event_publisher.dart
Normal 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.
|
||||
}
|
||||
}
|
||||
43
packages/halkyon_interop/lib/src/events.dart
Normal file
43
packages/halkyon_interop/lib/src/events.dart
Normal 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)';
|
||||
}
|
||||
53
packages/halkyon_interop/lib/src/task_shape.dart
Normal file
53
packages/halkyon_interop/lib/src/task_shape.dart
Normal 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)';
|
||||
}
|
||||
14
packages/halkyon_interop/pubspec.yaml
Normal file
14
packages/halkyon_interop/pubspec.yaml
Normal 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
|
||||
174
packages/halkyon_interop/test/halkyon_interop_test.dart
Normal file
174
packages/halkyon_interop/test/halkyon_interop_test.dart
Normal 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>());
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue