feat(learn): share a studyset with people from its Share sheet

Share opens one sheet (Halkyon's ADR 0044, D29): the people first, then
the community. A studyset of the person's own offers Share with people,
which says what it does, moves it into a space of its own with its
progress told and Abort, and opens it there (StudysetSharingCubit). A
shared one lists its members (SpaceMembersCubit): the Owner invites by
handle as Editor or Viewer, changes roles, hands on and removes, and
deletes it for everyone; anyone leaves, the Owner naming who takes it
on; each refusal is said. A member's row opens their safety code
(twelve groups of five digits) to mark compared, and changed keys are
flagged, trusted only after comparing (D32). The community row leads to
the privacy dialog or publishing as Share did, and is off for a shared
studyset (D27).

A studyset knows its space and whether the person only views it
(Studyset.sharedIn, viewOnly, from the sync engine's grants). Its mark
is Lucide's users icon, "Shared · private" (D31). A Viewer's studyset
screen, its options, its topics and the move sheet offer nothing that
writes, and say they can view and study it (D30); the engine refuses
their writes on the device in any case. A shared top isn't moved,
archived or deleted from its options, and nothing moves in or out of
its space.

Whats-New: Share a studyset with people: invite them by their handle as editors or viewers, and compare safety codes to know nobody stands between you.
Whats-New-De: Teile ein Lernset mit Personen: Lade sie über ihr Handle als Bearbeiter:innen oder Betrachter:innen ein und vergleicht Sicherheitscodes, damit niemand zwischen euch steht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-11 15:26:47 +01:00
commit 9b344141ef
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
14 changed files with 2777 additions and 118 deletions

View file

@ -255,6 +255,9 @@ void main() {
);
}
expect(await revisionKeys(card), [key.value]);
final sharing = await laptop.studysets.sharing();
expect(sharing[top], (space: space, viewOnly: false));
expect(sharing[child], (space: space, viewOnly: false));
expect(
await protocol.Card.db.count(
db,
@ -345,6 +348,9 @@ class _Lookup() implements SpaceLookup {
@override
Future<Map<String, Set<String>>> sharedStudysets() async => shared;
@override
Future<Set<String>> readOnlySpaces() async => const {};
@override
Future<String?> studysetOfCard(String id) async => null;

View file

@ -19,6 +19,9 @@ class _Lookup() implements SpaceLookup {
return shared;
}
@override
Future<Set<String>> readOnlySpaces() async => const {};
@override
Future<String?> studysetOfCard(String id) async => cards[id];

View file

@ -67,6 +67,37 @@ void main() {
});
});
test(
'it tells the spaces the person only views from those they change',
() async {
final lookup = SyncSpaceLookup(db, person.uuid);
expect(await lookup.readOnlySpaces(), isEmpty);
final viewed = const protocol.Uuid().v7obj();
await db.db.transactionForUser(
viewed,
(t) => protocol.Studyset.db.insertRow(
db,
protocol.Studyset(payload: payload),
transaction: t,
),
);
final row = await OfflineSyncSpace.db.findFirstRow(
db,
where: (t) => t.uuidSpaceId.equals(viewed),
);
await OfflineSyncSpaceMember.db.insertRow(
db,
OfflineSyncSpaceMember(
spaceId: row!.id!,
userUuid: person,
role: OfflineSyncSpaceRole.readOnly,
),
);
expect(await lookup.readOnlySpaces(), {viewed.uuid});
},
);
test("a shared studyset's new card and its revision go in its space, the "
"person's review of it in theirs", () async {
final card = await protocol.Card.db.insertRow(

View file

@ -1044,6 +1044,7 @@ void main() {
await tester.tap(find.byTooltip('Private'));
await tester.pumpAndSettle();
await openCommunity(tester);
expect(find.text('“Diary” is private'), findsOneWidget);
expect(find.text('Assistants it may go to'), findsOneWidget);
expect(find.text('Claude'), findsOneWidget);
@ -1070,6 +1071,7 @@ void main() {
await tester.tap(find.byTooltip('Private'));
await tester.pumpAndSettle();
await openCommunity(tester);
expect(find.text('Assistants it may go to'), findsNothing);
expect(find.text('Spelling and grammar'), findsOneWidget);
expect(
@ -1100,6 +1102,7 @@ void main() {
await tester.tap(find.byTooltip('Private'));
await tester.pumpAndSettle();
await openCommunity(tester);
expect(find.text('Claude'), findsOneWidget);
expect(find.text('Not checked as you write'), findsOneWidget);
expect(find.text('Take back'), findsNWidgets(2));
@ -1621,3 +1624,10 @@ void main() {
});
});
}
/// Taps the Share sheet's Community row (Halkyon's ADR 0044, D29), which
/// explains and changes what the instance sees of the studyset.
Future<void> openCommunity(WidgetTester tester) async {
await tester.tap(find.widgetWithIcon(ListTile, LucideIcons.chevronRight));
await tester.pumpAndSettle();
}

View file

@ -99,12 +99,13 @@ void main() {
await tester.pumpAndSettle();
}
/// Opens the Publish sheet from Share: at once once published, else
/// through what its privacy means, which leads there while it isn't
/// private (ADR 0019).
/// Opens the Publish sheet from Share's Community row (Halkyon's ADR
/// 0044, D29): at once once published, else through what its privacy
/// means, which leads there while it isn't private (ADR 0019).
Future<void> openSheet(WidgetTester tester) async {
await tester.tap(find.text('Share'));
await tester.pumpAndSettle();
await openCommunity(tester);
if (find.text('Publish…').evaluate().isNotEmpty) {
await tester.tap(find.text('Publish…'));
await tester.pumpAndSettle();
@ -409,6 +410,7 @@ void main() {
expect(find.byTooltip('Share: not private'), findsOneWidget);
await tester.tap(find.byTooltip('Share: not private'));
await tester.pumpAndSettle();
await openCommunity(tester);
await tester.tap(find.text('Publish…'));
await tester.pumpAndSettle();
expect(
@ -504,3 +506,9 @@ void main() {
expect(find.text('Continue'), findsOneWidget);
});
}
/// Taps the Share sheet's Community row, which leads to publishing.
Future<void> openCommunity(WidgetTester tester) async {
await tester.tap(find.widgetWithIcon(ListTile, LucideIcons.chevronRight));
await tester.pumpAndSettle();
}

View file

@ -0,0 +1,124 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
import '../../support/fake_studyset_gateway.dart';
import '../../support/pump_app.dart';
import '../../support/test_dependencies.dart';
/// The Share sheet (Halkyon's ADR 0044, D29, D30, D31): the people a
/// studyset is shared with first, or sharing it with them; the community
/// after, not for a shared studyset yet (D27); and a Viewer manages
/// nobody and adds nothing.
void main() {
late FakeStudysetGateway studysets;
late FakeSharedSpaceServer spaces;
late String space;
setUp(() async {
studysets = FakeStudysetGateway();
spaces = FakeSharedSpaceServer(FakeSharedKeysServer())
..addPerson('owner', handle: 'owner')
..addPerson('person', handle: 'person');
space = await spaces.as('owner').create('studyset');
studysets.receiveRemoteChange(
'japanese',
encodeStudysetPayload(
StudysetContent(title: 'Japanese', description: RichContent.empty()),
),
);
});
Future<void> showStudyset(WidgetTester tester) async {
tester.view.physicalSize = const Size(1200, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
authGateway: FakeAuthGateway(
status: const SignedIn(authUserId: 'person', isIdentified: true),
),
studysetGateway: studysets,
sharedSpaces: spaces.as('person'),
),
location: RoutePaths.studyset('japanese'),
);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
}
testWidgets("a studyset of the person's own offers sharing it with "
'people, then the community', (tester) async {
await showStudyset(tester);
await tester.tap(find.text('Share'));
await tester.pumpAndSettle();
expect(find.text('People'), findsOneWidget);
expect(find.text('Share with people'), findsOneWidget);
expect(find.text('Community'), findsOneWidget);
});
testWidgets('a shared studyset shows its members, its mark, and no '
'publishing yet', (tester) async {
await spaces.as('owner').invite(space, 'person', SpaceRole.editor);
studysets.shared['japanese'] = (space: space, viewOnly: false);
await showStudyset(tester);
expect(find.text('Shared · private'), findsNothing);
await tester.tap(find.text('Share'));
await tester.pumpAndSettle();
expect(find.text('@owner'), findsOneWidget);
expect(find.text('Owner'), findsOneWidget);
expect(find.text('@person'), findsOneWidget);
expect(find.text('Editor · you'), findsOneWidget);
expect(find.text('Share with people'), findsNothing);
expect(
find.text('Not for studysets shared with people yet.'),
findsOneWidget,
);
// Only the Owner invites and deletes it; anyone leaves.
expect(find.text('Invite'), findsNothing);
expect(find.text('Delete for everyone'), findsNothing);
expect(find.text('Leave'), findsOneWidget);
});
testWidgets('a Viewer adds nothing to it and is told so', (tester) async {
await spaces.as('owner').invite(space, 'person', SpaceRole.viewer);
studysets.shared['japanese'] = (space: space, viewOnly: true);
await showStudyset(tester);
expect(
find.text('You can view and study this, not change it.'),
findsOneWidget,
);
expect(find.text('Add material'), findsNothing);
expect(find.text('New sub-studyset'), findsNothing);
});
testWidgets('a member who leaves is asked first, and returns to the '
'library', (tester) async {
await spaces.as('owner').invite(space, 'person', SpaceRole.editor);
studysets.shared['japanese'] = (space: space, viewOnly: false);
await showStudyset(tester);
await tester.tap(find.text('Share'));
await tester.pumpAndSettle();
await tester.tap(find.text('Leave'));
await tester.pumpAndSettle();
expect(find.text('Leave Japanese?'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Leave'));
await tester.pumpAndSettle();
expect(find.byType(SheetScaffold), findsNothing);
expect(await spaces.as('person').mine(), isEmpty);
});
}

View file

@ -0,0 +1,151 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_learn_flutter/src/feature/sharing/bloc/space_members_cubit.dart';
/// A shared space's members as its Share sheet shows them (Halkyon's ADR
/// 0044, D29, D32): read with the person's role, changed through the
/// instance, each refusal said, and nothing changed by one.
void main() {
late FakeSharedSpaceServer server;
late String space;
SharedKeys keysOf(FakeSharedKeysServer keys, String person) => SharedKeys(
keys.as(person),
KeyPins(MemoryKeyPinStore()),
FakeContentCipher(),
me: person,
);
SpaceMembersCubit cubitOf(String person) =>
SpaceMembersCubit(server.as(person), keysOf(server.keys, person), space);
setUp(() async {
server = FakeSharedSpaceServer(FakeSharedKeysServer())
..addPerson('alice', handle: 'alice')
..addPerson('bob', handle: 'bob')
..addPerson('carol', handle: 'carol');
space = await server.as('alice').create('studyset');
});
test("reads the members with the person's own role", () async {
await server.as('alice').invite(space, 'bob', SpaceRole.viewer);
final cubit = cubitOf('bob');
addTearDown(cubit.close);
await cubit.load();
final state = cubit.state as SpaceMembersStateReady;
expect(state.members.map((m) => m.handle), ['alice', 'bob']);
expect(state.yourRole, SpaceRole.viewer);
});
test('the Owner invites and changes roles; the list follows', () async {
final cubit = cubitOf('alice');
addTearDown(cubit.close);
await cubit.load();
expect(
await cubit.invite(' bob ', SpaceRole.editor),
isA<SpaceActionDone>(),
);
var state = cubit.state as SpaceMembersStateReady;
final bob = state.members.singleWhere((m) => m.handle == 'bob');
expect(bob.role, SpaceRole.editor);
expect(
await cubit.setRole(bob, SpaceRole.viewer),
isA<SpaceActionDone>(),
);
state = cubit.state as SpaceMembersStateReady;
expect(
state.members.singleWhere((m) => m.handle == 'bob').role,
SpaceRole.viewer,
);
});
test('a refusal is said, and changes nothing', () async {
final cubit = cubitOf('alice');
addTearDown(cubit.close);
await cubit.load();
final before = cubit.state;
expect(
await cubit.invite('nobody', SpaceRole.editor),
isA<SpaceActionRefused>().having(
(a) => a.reason,
'reason',
SpaceRefusal.noSuchHandle,
),
);
expect(cubit.state, before);
});
test("a member who isn't the Owner manages nobody", () async {
await server.as('alice').invite(space, 'bob', SpaceRole.editor);
await server.as('alice').invite(space, 'carol', SpaceRole.viewer);
final cubit = cubitOf('bob');
addTearDown(cubit.close);
await cubit.load();
final carol = (cubit.state as SpaceMembersStateReady).members.singleWhere(
(m) => m.handle == 'carol',
);
final Matcher notAllowed = isA<SpaceActionRefused>().having(
(a) => a.reason,
'reason',
SpaceRefusal.notAllowed,
);
expect(
await cubit.invite('carol', SpaceRole.editor),
isA<SpaceActionRefused>(),
);
expect(await cubit.setRole(carol, SpaceRole.editor), notAllowed);
expect(await cubit.remove(carol), notAllowed);
expect(await cubit.handOn(carol), notAllowed);
expect(await cubit.delete(), notAllowed);
expect(
(await server.as('alice').members(space))
.singleWhere((m) => m.handle == 'carol')
.role,
SpaceRole.viewer,
);
});
test('the Owner leaves only naming who takes it on', () async {
await server.as('alice').invite(space, 'bob', SpaceRole.editor);
final cubit = cubitOf('alice');
addTearDown(cubit.close);
await cubit.load();
final bob = (cubit.state as SpaceMembersStateReady).members.singleWhere(
(m) => m.handle == 'bob',
);
expect(
await cubit.leave(),
isA<SpaceActionRefused>().having(
(a) => a.reason,
'reason',
SpaceRefusal.nextOwnerNeeded,
),
);
expect(await cubit.leave(nextOwner: bob), isA<SpaceActionDone>());
expect(await server.as('alice').mine(), isEmpty);
expect(await server.as('bob').mine(), [
(id: space, kind: 'studyset', role: SpaceRole.owner),
]);
});
test('a space out of reach is said so', () async {
final cubit = SpaceMembersCubit(
server.as('mallory'),
keysOf(server.keys, 'mallory'),
space,
);
addTearDown(cubit.close);
await cubit.load();
expect(cubit.state, isA<SpaceMembersStateFailed>());
});
}

View file

@ -37,7 +37,6 @@ import '../../support/fake_learning_path_gateway.dart';
import '../../support/fake_note_gateway.dart';
import '../../support/fake_path_answer_gateway.dart';
import '../../support/fake_pdf_source_gateway.dart';
import '../../support/fake_publish_gateway.dart';
import '../../support/fake_review_gateway.dart';
import '../../support/fake_studyset_gateway.dart';
import '../../support/fake_tag_gateway.dart';
@ -85,7 +84,7 @@ void main() {
spaces,
setup,
studysets,
FakePublishGateway(publications: publications),
(id) async => publications[id],
ArchiveExporter(
studysets,
cards,

View file

@ -110,6 +110,9 @@ class _Lookup() implements SpaceLookup {
@override
Future<Map<String, Set<String>>> sharedStudysets() async => shared;
@override
Future<Set<String>> readOnlySpaces() async => const {};
@override
Future<String?> studysetOfCard(String id) async => null;

View file

@ -370,4 +370,22 @@ void main() {
expect(titles(await repository.watchAll().first), ['Readable']);
});
test("a studyset in a shared space says so, and whether it's the "
"person's to change there (ADR 0044, D30, D31)", () async {
final own = await repository.create(StudysetContent.titled('Own'));
final shared = await repository.create(StudysetContent.titled('Shared'));
final viewed = await repository.create(StudysetContent.titled('Viewed'));
gateway.shared
..[shared] = (space: 'space-1', viewOnly: false)
..[viewed] = (space: 'space-2', viewOnly: true);
final all = {for (final s in await repository.withDeleted()) s.id: s};
expect(all[own]!.isShared, isFalse);
expect(all[own]!.viewOnly, isFalse);
expect(all[shared]!.sharedIn, 'space-1');
expect(all[shared]!.viewOnly, isFalse);
expect(all[viewed]!.sharedIn, 'space-2');
expect(all[viewed]!.viewOnly, isTrue);
});
}

View file

@ -1269,6 +1269,7 @@ void main() {
onCard('Japanese', find.byTooltip('Not private, not published')),
);
await tester.pumpAndSettle();
await openCommunity(tester);
expect(find.text("“Japanese” isn't private"), findsOneWidget);
await tester.tap(find.text('Publish…'));
await tester.pumpAndSettle();
@ -1283,6 +1284,7 @@ void main() {
onCard('Japanese', find.byTooltip('Not private, not published')),
);
await tester.pumpAndSettle();
await openCommunity(tester);
// Made private in Edit alone (the developer's review of 3 Nov 2026).
expect(find.text('Make private'), findsNothing);
expect(gateway.privacyChanges, isEmpty);
@ -1295,6 +1297,7 @@ void main() {
await tester.tap(onCard('Diary', find.byTooltip('Private')));
await tester.pumpAndSettle();
await openCommunity(tester);
expect(find.text('“Diary” is private'), findsOneWidget);
expect(find.text('Publish…'), findsNothing);
expect(find.text('Make non-private'), findsNothing);
@ -1313,9 +1316,17 @@ void main() {
await tester.tap(onCard('Japanese', find.byTooltip('Public')));
await tester.pumpAndSettle();
await openCommunity(tester);
expect(find.text('Publish'), findsOneWidget);
expect(find.textContaining('halkyon.eu/@raisondetredev'), findsWidgets);
});
});
}
/// Taps the Share sheet's Community row (Halkyon's ADR 0044, D29), which
/// explains and changes what the instance sees of the studyset.
Future<void> openCommunity(WidgetTester tester) async {
await tester.tap(find.widgetWithIcon(ListTile, LucideIcons.chevronRight));
await tester.pumpAndSettle();
}

View file

@ -0,0 +1,37 @@
import 'package:halkyon_learn_flutter/src/feature/sharing/data/studyset_sharing.dart';
import 'fake_studyset_gateway.dart';
/// [SpaceSetup] for tests: every space reaches the device at once, unless
/// [reaches] says otherwise; a placed studyset is marked shared in [rows],
/// as the router places it on a device.
class FakeSpaceSetup({
/// The studysets' gateway, whose rows a placed studyset is shared in.
final FakeStudysetGateway? rows,
}) implements SpaceSetup {
@override
bool canSeal = true;
/// Whether a new space reaches the device.
bool reaches = true;
/// The spaces readied, in order.
final readied = <String>[];
/// The studysets placed, by id, with their spaces.
final placed = <String, String>{};
@override
Future<void> ready(String space) async {
if (!reaches) {
throw const SharingRefused(SharingRefusal.notReached);
}
readied.add(space);
}
@override
void placeIn(String studysetId, String space) {
placed[studysetId] = space;
rows?.shared[studysetId] = (space: space, viewOnly: false);
}
}

View file

@ -138,6 +138,12 @@ class FakeStudysetGateway() implements StudysetGateway {
return 0;
}
/// The studysets in shared spaces, as [sharing] gives them.
final shared = <String, StudysetSharing>{};
@override
Future<Map<String, StudysetSharing>> sharing() async => {...shared};
@override
Future<List<StudysetRow>> all() async {
reads++;

View file

@ -66,6 +66,7 @@ import 'package:halkyon_learn_flutter/src/feature/publish/data/publish_repositor
import 'package:halkyon_learn_flutter/src/feature/publish/data/published_store.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/sharing/data/studyset_sharing.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
@ -99,6 +100,7 @@ import 'fake_publish_gateway.dart';
import 'fake_review_gateway.dart';
import 'fake_settings_gateway.dart';
import 'fake_sound_player.dart';
import 'fake_space_setup.dart';
import 'fake_studyset_gateway.dart';
import 'fake_tag_gateway.dart';
import 'fake_youtube_videos.dart';
@ -161,6 +163,9 @@ Dependencies testDependencies({
FeedbackWriteGateway? feedbackWriteGateway,
FeedbackServerGateway? feedbackServerGateway,
DiscussionGateway? discussionGateway,
SharedSpaceGateway? sharedSpaces,
SharedKeys? sharedKeys,
SpaceSetup? spaceSetup,
SyncOnce? syncOnce,
PendingChangesStore? pendingChanges,
// A device that synced before, as most are; false for one whose first
@ -416,6 +421,18 @@ Dependencies testDependencies({
grammarGateway ?? FakeGrammarGateway(),
() async => grammarLanguages,
),
sharedSpaces:
sharedSpaces ??
FakeSharedSpaceServer(FakeSharedKeysServer()).as('person'),
sharedKeys:
sharedKeys ??
SharedKeys(
FakeSharedKeysServer().as('person'),
KeyPins(MemoryKeyPinStore()),
FakeContentCipher(),
me: 'person',
),
spaceSetup: spaceSetup ?? FakeSpaceSetup(),
onDispose: () async {
await deletions.dispose();
await noteDeletions.dispose();