feat(studyset): make a studyset private or not, and what a locked one shows (ADR 0019)

- A top-level studyset's options make it private, with everything inside
  it, or non-private again, after a dialog saying what changes and that
  the instance's backups keep readable copies until they expire; a device
  without the account's keys says how to get them there.
- A locked studyset (its key doesn't open here) is titled Locked, says
  why when opened, and offers only Delete.
- Publishing names the person's tags as the device reads them, and says
  what to do when the studyset is private or a tag has no name.
- The export sheet says an archive is readable by whoever has the file.

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-21 21:40:27 +02:00
commit 0e10afcc68
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
19 changed files with 572 additions and 9 deletions

View file

@ -2060,5 +2060,24 @@
"watchStretchSearch": "Auf {site} suchen", "watchStretchSearch": "Auf {site} suchen",
"watchTapAWord": "Tipp auf ein Wort der Untertitel, um es nachzuschlagen, oder zieh über Zeichen, um einen beliebigen Teil einer Zeile nachzuschlagen (auf einem Touchscreen erst gedrückt halten).", "watchTapAWord": "Tipp auf ein Wort der Untertitel, um es nachzuschlagen, oder zieh über Zeichen, um einen beliebigen Teil einer Zeile nachzuschlagen (auf einem Touchscreen erst gedrückt halten).",
"watchFormOf": "Eine Form von {word}", "watchFormOf": "Eine Form von {word}",
"watchFailed": "Das Video konnte nicht abgespielt werden." "watchFailed": "Das Video konnte nicht abgespielt werden.",
"publishPrivateRefused": "Es ist privat: Nur deine Geräte können es lesen. Mach es zuerst in seinem Menü nicht mehr privat.",
"publishTagNames": "Nicht alle seine Tags ließen sich benennen. Synchronisiere und versuche es noch einmal.",
"studysetMenuMakePrivate": "Privat machen",
"studysetMenuMakeReadable": "Nicht mehr privat",
"studysetPrivateTitle": "„{title}“ privat machen?",
"studysetPrivateBody": "Alles darin wird auf deinen Geräten Ende-zu-Ende-verschlüsselt: Die Instanz kann es weder lesen noch veröffentlichen noch Claude zeigen. Kopien in den Sicherungen der Instanz bleiben lesbar, bis diese ablaufen.",
"studysetReadableTitle": "„{title}“ nicht mehr privat?",
"studysetReadableBody": "Alles darin wird entschlüsselt und neu synchronisiert, sodass die Instanz es lesen kann: Du kannst es veröffentlichen, und Claude kann es über den Connector lesen.",
"studysetPrivateNoKeys": "Melde dich auf diesem Gerät mit deinem Passwort an, um Lernsets privat zu machen.",
"studysetPrivateSomeLocked": "Manches darin ließ sich auf diesem Gerät nicht öffnen und blieb, wie es war.",
"studysetPrivateLabel": "Privat",
"studysetLockedTitle": "Gesperrt",
"studysetLockedBody": "Dieses Lernset ist privat, und sein Schlüssel öffnet sich auf diesem Gerät nicht: Es wurde mit Schlüsseln verschlüsselt, die dein Konto nicht mehr hat. Es lässt sich nur noch löschen.",
"libraryOfferPrivateTitle": "Deine Lernsets privat machen?",
"libraryOfferPrivateBody": "Neue Lernsets sind privat: Ende-zu-Ende-verschlüsselt, sodass nur deine Geräte sie lesen können. Die, die du vorher gemacht hast, bleiben für die Instanz lesbar, bis du sie auch privat machst. Kopien in den Sicherungen der Instanz bleiben lesbar, bis diese ablaufen.",
"libraryOfferPrivateAction": "Alle privat machen",
"libraryOfferPrivateLater": "Jetzt nicht",
"exportReadableNote": "Das Archiv ist nicht verschlüsselt: Wer die Datei hat, kann es lesen."
} }

View file

@ -2424,5 +2424,26 @@
"watchTapAWord": "Tap a word of the subtitles to look it up, or drag across characters to look up any part of a line (on a touch screen, hold first).", "watchTapAWord": "Tap a word of the subtitles to look it up, or drag across characters to look up any part of a line (on a touch screen, hold first).",
"watchFormOf": "A form of {word}", "watchFormOf": "A form of {word}",
"@watchFormOf": {"placeholders": {"word": {"type": "String"}}}, "@watchFormOf": {"placeholders": {"word": {"type": "String"}}},
"watchFailed": "The video couldn't be played." "watchFailed": "The video couldn't be played.",
"publishPrivateRefused": "It's private: only your devices can read it. Make it non-private first, in its menu.",
"publishTagNames": "Its tags couldn't all be named. Sync, then try again.",
"studysetMenuMakePrivate": "Make private",
"studysetMenuMakeReadable": "Make non-private",
"studysetPrivateTitle": "Make “{title}” private?",
"@studysetPrivateTitle": {"placeholders": {"title": {"type": "String"}}},
"studysetPrivateBody": "Everything in it is end-to-end encrypted on your devices: the instance can't read it, publish it or show it to Claude. Copies in the instance's backups stay readable until those expire.",
"studysetReadableTitle": "Make “{title}” non-private?",
"@studysetReadableTitle": {"placeholders": {"title": {"type": "String"}}},
"studysetReadableBody": "Everything in it is decrypted and synced again, so the instance can read it: you can publish it, and Claude can read it through the connector.",
"studysetPrivateNoKeys": "Sign in with your password on this device to make studysets private.",
"studysetPrivateSomeLocked": "Some of it couldn't be opened on this device and stayed as it was.",
"studysetPrivateLabel": "Private",
"studysetLockedTitle": "Locked",
"studysetLockedBody": "This studyset is private, and its key doesn't open on this device: it was encrypted with keys your account no longer has. It can only be deleted.",
"libraryOfferPrivateTitle": "Make your studysets private?",
"libraryOfferPrivateBody": "New studysets are private: end-to-end encrypted, so only your devices can read them. Those you made before stay readable by the instance until you make them private too. Copies in the instance's backups stay readable until those expire.",
"libraryOfferPrivateAction": "Make all private",
"libraryOfferPrivateLater": "Not now",
"exportReadableNote": "The archive isn't encrypted: whoever has the file can read it."
} }

View file

@ -12158,6 +12158,114 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'The video couldn\'t be played.'** /// **'The video couldn\'t be played.'**
String get watchFailed; String get watchFailed;
/// No description provided for @publishPrivateRefused.
///
/// In en, this message translates to:
/// **'It\'s private: only your devices can read it. Make it non-private first, in its menu.'**
String get publishPrivateRefused;
/// No description provided for @publishTagNames.
///
/// In en, this message translates to:
/// **'Its tags couldn\'t all be named. Sync, then try again.'**
String get publishTagNames;
/// No description provided for @studysetMenuMakePrivate.
///
/// In en, this message translates to:
/// **'Make private'**
String get studysetMenuMakePrivate;
/// No description provided for @studysetMenuMakeReadable.
///
/// In en, this message translates to:
/// **'Make non-private'**
String get studysetMenuMakeReadable;
/// No description provided for @studysetPrivateTitle.
///
/// In en, this message translates to:
/// **'Make “{title}” private?'**
String studysetPrivateTitle(String title);
/// No description provided for @studysetPrivateBody.
///
/// In en, this message translates to:
/// **'Everything in it is end-to-end encrypted on your devices: the instance can\'t read it, publish it or show it to Claude. Copies in the instance\'s backups stay readable until those expire.'**
String get studysetPrivateBody;
/// No description provided for @studysetReadableTitle.
///
/// In en, this message translates to:
/// **'Make “{title}” non-private?'**
String studysetReadableTitle(String title);
/// No description provided for @studysetReadableBody.
///
/// In en, this message translates to:
/// **'Everything in it is decrypted and synced again, so the instance can read it: you can publish it, and Claude can read it through the connector.'**
String get studysetReadableBody;
/// No description provided for @studysetPrivateNoKeys.
///
/// In en, this message translates to:
/// **'Sign in with your password on this device to make studysets private.'**
String get studysetPrivateNoKeys;
/// No description provided for @studysetPrivateSomeLocked.
///
/// In en, this message translates to:
/// **'Some of it couldn\'t be opened on this device and stayed as it was.'**
String get studysetPrivateSomeLocked;
/// No description provided for @studysetPrivateLabel.
///
/// In en, this message translates to:
/// **'Private'**
String get studysetPrivateLabel;
/// No description provided for @studysetLockedTitle.
///
/// In en, this message translates to:
/// **'Locked'**
String get studysetLockedTitle;
/// No description provided for @studysetLockedBody.
///
/// In en, this message translates to:
/// **'This studyset is private, and its key doesn\'t open on this device: it was encrypted with keys your account no longer has. It can only be deleted.'**
String get studysetLockedBody;
/// No description provided for @libraryOfferPrivateTitle.
///
/// In en, this message translates to:
/// **'Make your studysets private?'**
String get libraryOfferPrivateTitle;
/// No description provided for @libraryOfferPrivateBody.
///
/// In en, this message translates to:
/// **'New studysets are private: end-to-end encrypted, so only your devices can read them. Those you made before stay readable by the instance until you make them private too. Copies in the instance\'s backups stay readable until those expire.'**
String get libraryOfferPrivateBody;
/// No description provided for @libraryOfferPrivateAction.
///
/// In en, this message translates to:
/// **'Make all private'**
String get libraryOfferPrivateAction;
/// No description provided for @libraryOfferPrivateLater.
///
/// In en, this message translates to:
/// **'Not now'**
String get libraryOfferPrivateLater;
/// No description provided for @exportReadableNote.
///
/// In en, this message translates to:
/// **'The archive isn\'t encrypted: whoever has the file can read it.'**
String get exportReadableNote;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -7705,4 +7705,71 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get watchFailed => 'Das Video konnte nicht abgespielt werden.'; String get watchFailed => 'Das Video konnte nicht abgespielt werden.';
@override
String get publishPrivateRefused =>
'Es ist privat: Nur deine Geräte können es lesen. Mach es zuerst in seinem Menü nicht mehr privat.';
@override
String get publishTagNames =>
'Nicht alle seine Tags ließen sich benennen. Synchronisiere und versuche es noch einmal.';
@override
String get studysetMenuMakePrivate => 'Privat machen';
@override
String get studysetMenuMakeReadable => 'Nicht mehr privat';
@override
String studysetPrivateTitle(String title) {
return '„$title“ privat machen?';
}
@override
String get studysetPrivateBody =>
'Alles darin wird auf deinen Geräten Ende-zu-Ende-verschlüsselt: Die Instanz kann es weder lesen noch veröffentlichen noch Claude zeigen. Kopien in den Sicherungen der Instanz bleiben lesbar, bis diese ablaufen.';
@override
String studysetReadableTitle(String title) {
return '„$title“ nicht mehr privat?';
}
@override
String get studysetReadableBody =>
'Alles darin wird entschlüsselt und neu synchronisiert, sodass die Instanz es lesen kann: Du kannst es veröffentlichen, und Claude kann es über den Connector lesen.';
@override
String get studysetPrivateNoKeys =>
'Melde dich auf diesem Gerät mit deinem Passwort an, um Lernsets privat zu machen.';
@override
String get studysetPrivateSomeLocked =>
'Manches darin ließ sich auf diesem Gerät nicht öffnen und blieb, wie es war.';
@override
String get studysetPrivateLabel => 'Privat';
@override
String get studysetLockedTitle => 'Gesperrt';
@override
String get studysetLockedBody =>
'Dieses Lernset ist privat, und sein Schlüssel öffnet sich auf diesem Gerät nicht: Es wurde mit Schlüsseln verschlüsselt, die dein Konto nicht mehr hat. Es lässt sich nur noch löschen.';
@override
String get libraryOfferPrivateTitle => 'Deine Lernsets privat machen?';
@override
String get libraryOfferPrivateBody =>
'Neue Lernsets sind privat: Ende-zu-Ende-verschlüsselt, sodass nur deine Geräte sie lesen können. Die, die du vorher gemacht hast, bleiben für die Instanz lesbar, bis du sie auch privat machst. Kopien in den Sicherungen der Instanz bleiben lesbar, bis diese ablaufen.';
@override
String get libraryOfferPrivateAction => 'Alle privat machen';
@override
String get libraryOfferPrivateLater => 'Jetzt nicht';
@override
String get exportReadableNote =>
'Das Archiv ist nicht verschlüsselt: Wer die Datei hat, kann es lesen.';
} }

View file

@ -7633,6 +7633,73 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get watchFailed => 'The video couldn\'t be played.'; String get watchFailed => 'The video couldn\'t be played.';
@override
String get publishPrivateRefused =>
'It\'s private: only your devices can read it. Make it non-private first, in its menu.';
@override
String get publishTagNames =>
'Its tags couldn\'t all be named. Sync, then try again.';
@override
String get studysetMenuMakePrivate => 'Make private';
@override
String get studysetMenuMakeReadable => 'Make non-private';
@override
String studysetPrivateTitle(String title) {
return 'Make “$title” private?';
}
@override
String get studysetPrivateBody =>
'Everything in it is end-to-end encrypted on your devices: the instance can\'t read it, publish it or show it to Claude. Copies in the instance\'s backups stay readable until those expire.';
@override
String studysetReadableTitle(String title) {
return 'Make “$title” non-private?';
}
@override
String get studysetReadableBody =>
'Everything in it is decrypted and synced again, so the instance can read it: you can publish it, and Claude can read it through the connector.';
@override
String get studysetPrivateNoKeys =>
'Sign in with your password on this device to make studysets private.';
@override
String get studysetPrivateSomeLocked =>
'Some of it couldn\'t be opened on this device and stayed as it was.';
@override
String get studysetPrivateLabel => 'Private';
@override
String get studysetLockedTitle => 'Locked';
@override
String get studysetLockedBody =>
'This studyset is private, and its key doesn\'t open on this device: it was encrypted with keys your account no longer has. It can only be deleted.';
@override
String get libraryOfferPrivateTitle => 'Make your studysets private?';
@override
String get libraryOfferPrivateBody =>
'New studysets are private: end-to-end encrypted, so only your devices can read them. Those you made before stay readable by the instance until you make them private too. Copies in the instance\'s backups stay readable until those expire.';
@override
String get libraryOfferPrivateAction => 'Make all private';
@override
String get libraryOfferPrivateLater => 'Not now';
@override
String get exportReadableNote =>
'The archive isn\'t encrypted: whoever has the file can read it.';
} }
/// The translations for English, as used in the United Kingdom (`en_GB`). /// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -38,6 +38,8 @@ import 'package:halkyon_learn_flutter/src/feature/discussion/data/discussion_rep
import 'package:halkyon_learn_flutter/src/feature/discussion/data/serverpod_discussion_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/discussion/data/serverpod_discussion_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/copy_updater.dart'; import 'package:halkyon_learn_flutter/src/feature/explore/data/copy_updater.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/learning_count_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/learning_counting.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.dart'; import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/serverpod_explore_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/explore/data/serverpod_explore_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/export/data/export_archive.dart'; import 'package:halkyon_learn_flutter/src/feature/export/data/export_archive.dart';
@ -459,6 +461,15 @@ Future<PersonDependencies> _openPersonData(
); );
final settings = SettingsRepository(SyncSettingsGateway(db, sync, privacy)); final settings = SettingsRepository(SyncSettingsGateway(db, sync, privacy));
final reviews = ReviewRepository(SyncReviewGateway(db, sync)); final reviews = ReviewRepository(SyncReviewGateway(db, sync));
// Count me and the copies' publications, which the instance can't read
// any more (ADR 0019, D3), for people with an identity: only they count.
final counting = person.isIdentified
? (LearningCounting(
ServerpodLearningCountGateway(client),
settings,
studysets,
)..start(sync.synced))
: null;
final assets = AssetRepository( final assets = AssetRepository(
SyncAssetGateway(db, sync), SyncAssetGateway(db, sync),
ServerpodAssetServerGateway(client), ServerpodAssetServerGateway(client),
@ -493,6 +504,9 @@ Future<PersonDependencies> _openPersonData(
final publishing = PublishRepository( final publishing = PublishRepository(
ServerpodPublishGateway(client), ServerpodPublishGateway(client),
FilePublishedStore(File(p.join(folder.path, 'published.json'))), FilePublishedStore(File(p.join(folder.path, 'published.json'))),
tagNames: () async => {
for (final tag in (await tags.read()).all) tag.id: tag.name,
},
); );
final studysetTabs = StudysetTabs( final studysetTabs = StudysetTabs(
FileStudysetTabStore(File(p.join(folder.path, 'studyset_tabs.json'))), FileStudysetTabStore(File(p.join(folder.path, 'studyset_tabs.json'))),
@ -552,6 +566,7 @@ Future<PersonDependencies> _openPersonData(
await stepDeletions.dispose(); await stepDeletions.dispose();
await pdfDeletions.dispose(); await pdfDeletions.dispose();
await discussions.dispose(); await discussions.dispose();
await counting?.dispose();
await studysetProgress.dispose(); await studysetProgress.dispose();
await studysets.dispose(); await studysets.dispose();
await tags.dispose(); await tags.dispose();

View file

@ -261,6 +261,17 @@ class _ExportSheetState() extends State<_ExportSheet> {
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading, controlAffinity: ListTileControlAffinity.leading,
), ),
// What leaves the device is decrypted (ADR 0019): whoever has the
// file reads it, whatever the studyset is.
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
l10n.exportReadableNote,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
], ],
), ),
); );

View file

@ -19,6 +19,7 @@ abstract interface class PublishGateway() {
required String slug, required String slug,
required String notes, required String notes,
RichContent? description, RichContent? description,
Map<String, String>? tagNames,
}); });
/// Takes the published studyset [studysetId] off Explore. /// Takes the published studyset [studysetId] off Explore.

View file

@ -14,8 +14,13 @@ import 'package:halkyon_learn_flutter/src/feature/publish/model/publication.dart
/// [_store], for the library to show offline too. /// [_store], for the library to show offline too.
class PublishRepository( class PublishRepository(
final PublishGateway _gateway, final PublishGateway _gateway,
final PublishedStore _store, final PublishedStore _store, {
) {
/// The person's tags' names by their ids, as this device reads them: the
/// instance can't read them (ADR 0019, D2), so a release's tags are named
/// with them; none without.
final Future<Map<String, String>> Function()? _tagNames,
}) {
final _changes = StreamController<Set<String>>.broadcast(); final _changes = StreamController<Set<String>>.broadcast();
/// The studysets out on Explore as last known; null until read. /// The studysets out on Explore as last known; null until read.
@ -61,6 +66,7 @@ class PublishRepository(
slug: slug.trim(), slug: slug.trim(),
notes: notes.trim(), notes: notes.trim(),
description: description, description: description,
tagNames: await _tagNames?.call(),
); );
await _rememberOne(studysetId, out: true); await _rememberOne(studysetId, out: true);
return publication; return publication;

View file

@ -28,6 +28,7 @@ class ServerpodPublishGateway(final protocol.Client _client)
required String slug, required String slug,
required String notes, required String notes,
RichContent? description, RichContent? description,
Map<String, String>? tagNames,
}) async { }) async {
try { try {
return _publication( return _publication(
@ -39,6 +40,7 @@ class ServerpodPublishGateway(final protocol.Client _client)
final description? => jsonEncode(description.toJson()), final description? => jsonEncode(description.toJson()),
null => null, null => null,
}, },
tagNames: tagNames,
), ),
); );
} on protocol.PublishRefusedException catch (error) { } on protocol.PublishRefusedException catch (error) {

View file

@ -124,6 +124,13 @@ enum PublishProblem() {
/// A copy of a published studyset under a licence that asks adaptations /// A copy of a published studyset under a licence that asks adaptations
/// to stay under it ([PublishRefused.license]). /// to stay under it ([PublishRefused.license]).
licenseMustStay, licenseMustStay,
/// The studyset is private (ADR 0019): it is made non-private first.
private,
/// The release's tags couldn't all be named: the person's tags are
/// encrypted, and this device didn't name one (ADR 0019, D2).
tagNamesMismatch,
} }
/// The instance refused to publish, for [problem]; [items] names the /// The instance refused to publish, for [problem]; [items] names the

View file

@ -608,4 +608,6 @@ String _problemText(
PublishProblem.licenseMustStay => l10n.publishLicenseMustStay( PublishProblem.licenseMustStay => l10n.publishLicenseMustStay(
license ?? '', license ?? '',
), ),
PublishProblem.private => l10n.publishPrivateRefused,
PublishProblem.tagNamesMismatch => l10n.publishTagNames,
}; };

View file

@ -127,6 +127,20 @@ class StudysetRepository(final StudysetGateway _gateway) {
await _refresh(); await _refresh();
} }
/// Whether this device can make a studyset private: it holds the
/// account's keys (ADR 0019, D8).
bool get canMakePrivate => _gateway.canMakePrivate;
/// Makes the top-level studyset [id] private, with everything inside it,
/// or readable by the instance again (ADR 0019): each of its rows is
/// written anew on this device and synced. Returns how many rows couldn't
/// be opened to do so, which stay as they were.
Future<int> setPrivate(String id, {required bool private}) async {
final locked = await _gateway.setPrivate(id, private: private);
await _refresh();
return locked;
}
/// Stars the studyset [id], keeping it at the top of the library. /// Stars the studyset [id], keeping it at the top of the library.
Future<void> star(String id) async { Future<void> star(String id) async {
final now = _now(); final now = _now();

View file

@ -17,6 +17,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.d
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/delete_studyset.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/widget/delete_studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_info.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_info.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_move_sheet.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_move_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_privacy.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_sheet.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_sheet.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -38,6 +39,8 @@ enum StudysetOption() {
unarchive, unarchive,
delete, delete,
info, info,
makePrivate,
makeReadable,
reportToAuthor, reportToAuthor,
feedback, feedback,
discussions, discussions,
@ -54,9 +57,11 @@ typedef StudysetOptionEntry = ({
/// [studyset]'s options, in order: edit, move, its study options, import /// [studyset]'s options, in order: edit, move, its study options, import
/// and export content, reset its progress, archive or unarchive, delete, /// and export content, reset its progress, archive or unarchive, delete,
/// what there is to know about it; for a copy of a published studyset, /// what there is to know about it; at the top level, making it private or
/// reporting to its author, its reports and suggestions, and the /// not (ADR 0019); for a copy of a published studyset, reporting to its
/// discussions within it (ADR 0013). /// author, its reports and suggestions, and the discussions within it (ADR
/// 0013). A locked studyset, whose key doesn't open here, can only be
/// deleted.
List<StudysetOptionEntry> studysetOptions( List<StudysetOptionEntry> studysetOptions(
AppLocalizations l10n, AppLocalizations l10n,
Studyset studyset, Studyset studyset,
@ -67,6 +72,16 @@ List<StudysetOptionEntry> studysetOptions(
String label, { String label, {
bool destroys = false, bool destroys = false,
}) => (option: option, icon: icon, label: label, destroys: destroys); }) => (option: option, icon: icon, label: label, destroys: destroys);
if (studyset.isLocked) {
return [
entry(
StudysetOption.delete,
LucideIcons.trash2,
l10n.studysetMenuDelete,
destroys: true,
),
];
}
return [ return [
entry(StudysetOption.edit, LucideIcons.pencil, l10n.studysetMenuEdit), entry(StudysetOption.edit, LucideIcons.pencil, l10n.studysetMenuEdit),
entry(StudysetOption.move, LucideIcons.folderInput, l10n.studysetMenuMove), entry(StudysetOption.move, LucideIcons.folderInput, l10n.studysetMenuMove),
@ -105,6 +120,19 @@ List<StudysetOptionEntry> studysetOptions(
destroys: true, destroys: true,
), ),
entry(StudysetOption.info, LucideIcons.info, l10n.moreInfo), entry(StudysetOption.info, LucideIcons.info, l10n.moreInfo),
if (studyset.parentId == null)
if (studyset.isPrivate)
entry(
StudysetOption.makeReadable,
LucideIcons.lockOpen,
l10n.studysetMenuMakeReadable,
)
else
entry(
StudysetOption.makePrivate,
LucideIcons.lock,
l10n.studysetMenuMakePrivate,
),
if (fromRelease(studyset.content.published)) ...[ if (fromRelease(studyset.content.published)) ...[
entry( entry(
StudysetOption.reportToAuthor, StudysetOption.reportToAuthor,
@ -203,6 +231,12 @@ Future<void> doStudysetOption(
deleteStudysetWithUndo(context, studyset, onDeleted: onDeleted); deleteStudysetWithUndo(context, studyset, onDeleted: onDeleted);
case StudysetOption.info: case StudysetOption.info:
await showStudysetInfo(context, studyset); await showStudysetInfo(context, studyset);
case StudysetOption.makePrivate || StudysetOption.makeReadable:
await setStudysetPrivacy(
context,
studyset,
private: option == StudysetOption.makePrivate,
);
case StudysetOption.reportToAuthor: case StudysetOption.reportToAuthor:
if (studyset.content.published case final origin?) { if (studyset.content.published case final origin?) {
await reportStudysetToAuthor(context, origin); await reportStudysetToAuthor(context, origin);

View file

@ -80,7 +80,10 @@ class _StudysetCardState() extends State<StudysetCard> {
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
child: InkWell( child: InkWell(
onTap: widget.onOpen, // A locked studyset (ADR 0019) has nothing to open: it says why.
onTap: studyset.isLocked
? () => showLockedStudyset(context)
: widget.onOpen,
onLongPress: _toggleStarByLongPress, onLongPress: _toggleStarByLongPress,
// Dimming doesn't stop taps: the options still take it out of the // Dimming doesn't stop taps: the options still take it out of the
// archive. // archive.
@ -97,7 +100,9 @@ class _StudysetCardState() extends State<StudysetCard> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
TitleWithKicker( TitleWithKicker(
title: studyset.content.title, title: studyset.isLocked
? AppLocalizations.of(context).studysetLockedTitle
: studyset.content.title,
kicker: studyset.content.secondaryTitle, kicker: studyset.content.secondaryTitle,
titleStyle: theme.textTheme.titleMedium?.copyWith( titleStyle: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -385,3 +390,23 @@ class const _By(final String text) extends StatelessWidget {
); );
} }
} }
/// Says why a locked studyset can't be opened (ADR 0019): its key doesn't
/// open on this device, and it can only be deleted, from its options.
Future<void> showLockedStudyset(BuildContext context) {
final l10n = AppLocalizations.of(context);
return showDialog<void>(
context: context,
builder: (context) => AlertDialog(
icon: const Icon(LucideIcons.lockKeyhole),
title: Text(l10n.studysetLockedTitle),
content: Text(l10n.studysetLockedBody),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(MaterialLocalizations.of(context).okButtonLabel),
),
],
),
);
}

View file

@ -0,0 +1,62 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:material_ui/material_ui.dart';
/// Makes the top-level [studyset] private, with everything inside it, or
/// readable by the instance again (ADR 0019), once the person agreed: the
/// dialog says what changes, and that the instance's backups keep readable
/// copies until they expire. Where this device can't encrypt (an account
/// whose keys aren't here), says how to.
Future<void> setStudysetPrivacy(
BuildContext context,
Studyset studyset, {
required bool private,
}) async {
final l10n = AppLocalizations.of(context);
final studysets = PersonDependencies.of(context).studysets;
final messenger = ScaffoldMessenger.of(context);
if (private && !studysets.canMakePrivate) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.studysetPrivateNoKeys)),
);
return;
}
final title = studyset.content.title;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(
private
? l10n.studysetPrivateTitle(title)
: l10n.studysetReadableTitle(title),
),
content: Text(
private ? l10n.studysetPrivateBody : l10n.studysetReadableBody,
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(
private
? l10n.studysetMenuMakePrivate
: l10n.studysetMenuMakeReadable,
),
),
],
),
);
if (confirmed != true) {
return;
}
final locked = await studysets.setPrivate(studyset.id, private: private);
if (locked > 0) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.studysetPrivateSomeLocked)),
);
}
}

View file

@ -0,0 +1,33 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/data/publish_repository.dart';
import '../../../support/fake_publish_gateway.dart';
import '../../../support/memory_published_store.dart';
/// The instance can't read the person's tags (ADR 0019, D2): publishing
/// names them as this device reads them.
void main() {
test("publishing sends the tags' names this device reads", () async {
final gateway = FakePublishGateway();
final publishing = PublishRepository(
gateway,
MemoryPublishedStore(),
tagNames: () async => {'tag-1': 'JLPT-N5'},
);
addTearDown(publishing.dispose);
await publishing.publish('studyset-1', slug: 'japanese', notes: '');
expect(gateway.lastTagNames, {'tag-1': 'JLPT-N5'});
});
test('without names to give, none are sent', () async {
final gateway = FakePublishGateway();
final publishing = PublishRepository(gateway, MemoryPublishedStore());
addTearDown(publishing.dispose);
await publishing.publish('studyset-1', slug: 'japanese', notes: '');
expect(gateway.lastTagNames, isNull);
});
}

View file

@ -0,0 +1,64 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_actions.dart';
/// Privacy in a studyset's options (ADR 0019): chosen at the top level,
/// and a locked studyset can only be deleted.
void main() {
final l10n = lookupAppLocalizations(const Locale('en'));
Set<StudysetOption> optionsOf(Studyset studyset) => {
for (final entry in studysetOptions(l10n, studyset)) entry.option,
};
test('a readable top-level studyset can be made private, a private one '
'non-private', () {
final readable = Studyset(
id: 's',
content: StudysetContent.titled('Diary'),
);
final private = Studyset(
id: 's',
content: StudysetContent.titled('Diary'),
isPrivate: true,
);
expect(optionsOf(readable), contains(StudysetOption.makePrivate));
expect(optionsOf(readable), isNot(contains(StudysetOption.makeReadable)));
expect(optionsOf(private), contains(StudysetOption.makeReadable));
expect(optionsOf(private), isNot(contains(StudysetOption.makePrivate)));
});
test("a topic's privacy is its tree's: it offers neither", () {
final topic = Studyset(
id: 't',
content: StudysetContent.titled('Kanji'),
parentId: 's',
isPrivate: true,
);
expect(
optionsOf(topic),
isNot(
anyOf(
contains(StudysetOption.makePrivate),
contains(StudysetOption.makeReadable),
),
),
);
});
test('a locked studyset offers nothing but deleting', () {
final locked = Studyset(
id: 's',
content: StudysetContent.titled(''),
isPrivate: true,
isLocked: true,
);
expect(optionsOf(locked), {StudysetOption.delete});
});
}

View file

@ -9,6 +9,9 @@ class FakePublishGateway({
/// The studysets published, by id. /// The studysets published, by id.
final Map<String, Publication> publications = const {}, final Map<String, Publication> publications = const {},
}) implements PublishGateway { }) implements PublishGateway {
/// The tag names sent with the last publish.
Map<String, String>? lastTagNames;
late final _publications = {...publications}; late final _publications = {...publications};
/// Refusals to answer the next publishes with, the first first. /// Refusals to answer the next publishes with, the first first.
@ -63,7 +66,9 @@ class FakePublishGateway({
required String slug, required String slug,
required String notes, required String notes,
RichContent? description, RichContent? description,
Map<String, String>? tagNames,
}) async { }) async {
lastTagNames = tagNames;
if (fail case final error?) { if (fail case final error?) {
throw error; throw error;
} }