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
3 changed files with 2777 additions and 118 deletions

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