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:
parent
02bb24428c
commit
9b344141ef
4 changed files with 2777 additions and 118 deletions
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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>());
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue