feat(learn): a studyset is made private in Edit, no longer from its options

The developer's review of 3 Nov 2026: Make private and Make non-private
leave a studyset's options (its card's menu, its screen's); a top-level
studyset's Edit has a Visibility section instead, its switch making it
private, or non-private, at once once the person agreed in the dialog as
before (ADR 0019), not on Save, and what that means behind the question
mark. A topic's Edit offers none, its privacy being its tree's. The lock on
a studyset's card still explains its privacy and keeps its own button.

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-03 21:50:00 +01:00
commit a7904bac05
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
10 changed files with 156 additions and 61 deletions

View file

@ -2129,5 +2129,7 @@
"studysetPrivacyReadableTitle": "„{title}“ ist nicht privat", "studysetPrivacyReadableTitle": "„{title}“ ist nicht privat",
"studysetPrivacyReadableBody": "Die Instanz kann es lesen: Du kannst es veröffentlichen. Privat wird es auf deinen Geräten Ende-zu-Ende-verschlüsselt; Kopien in den Sicherungen der Instanz bleiben lesbar, bis diese ablaufen.", "studysetPrivacyReadableBody": "Die Instanz kann es lesen: Du kannst es veröffentlichen. Privat wird es auf deinen Geräten Ende-zu-Ende-verschlüsselt; Kopien in den Sicherungen der Instanz bleiben lesbar, bis diese ablaufen.",
"studysetPrivacyTopic": "Seine Privatsphäre ist die des Lernsets, in dem es liegt.", "studysetPrivacyTopic": "Seine Privatsphäre ist die des Lernsets, in dem es liegt.",
"studysetPrivacyPublish": "Veröffentlichen …" "studysetPrivacyPublish": "Veröffentlichen …",
"studysetSheetVisibility": "Sichtbarkeit",
"studysetSheetPrivate": "Privat"
} }

View file

@ -2545,5 +2545,9 @@
"@studysetPrivacyReadableTitle": {"placeholders": {"title": {"type": "String"}}}, "@studysetPrivacyReadableTitle": {"placeholders": {"title": {"type": "String"}}},
"studysetPrivacyReadableBody": "The instance can read it: you can publish it. Making it private encrypts it end to end on your devices; copies in the instance's backups stay readable until those expire.", "studysetPrivacyReadableBody": "The instance can read it: you can publish it. Making it private encrypts it end to end on your devices; copies in the instance's backups stay readable until those expire.",
"studysetPrivacyTopic": "Its privacy is that of the studyset it's in.", "studysetPrivacyTopic": "Its privacy is that of the studyset it's in.",
"studysetPrivacyPublish": "Publish…" "studysetPrivacyPublish": "Publish…",
"studysetSheetVisibility": "Visibility",
"@studysetSheetVisibility": {"description": "Heading in a top-level studyset's Edit sheet above its privacy switch (ADR 0019)."},
"studysetSheetPrivate": "Private",
"@studysetSheetPrivate": {"description": "The switch making a top-level studyset private (end-to-end encrypted) in its Edit sheet."}
} }

View file

@ -12565,6 +12565,18 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Publish…'** /// **'Publish…'**
String get studysetPrivacyPublish; String get studysetPrivacyPublish;
/// Heading in a top-level studyset's Edit sheet above its privacy switch (ADR 0019).
///
/// In en, this message translates to:
/// **'Visibility'**
String get studysetSheetVisibility;
/// The switch making a top-level studyset private (end-to-end encrypted) in its Edit sheet.
///
/// In en, this message translates to:
/// **'Private'**
String get studysetSheetPrivate;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -8061,4 +8061,10 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get studysetPrivacyPublish => 'Veröffentlichen …'; String get studysetPrivacyPublish => 'Veröffentlichen …';
@override
String get studysetSheetVisibility => 'Sichtbarkeit';
@override
String get studysetSheetPrivate => 'Privat';
} }

View file

@ -7988,6 +7988,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get studysetPrivacyPublish => 'Publish…'; String get studysetPrivacyPublish => 'Publish…';
@override
String get studysetSheetVisibility => 'Visibility';
@override
String get studysetSheetPrivate => 'Private';
} }
/// 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

@ -39,8 +39,6 @@ enum StudysetOption() {
unarchive, unarchive,
delete, delete,
info, info,
makePrivate,
makeReadable,
reportToAuthor, reportToAuthor,
feedback, feedback,
discussions, discussions,
@ -133,20 +131,9 @@ List<StudysetOptionEntry> studysetOptions(
l10n.studysetMenuDelete, l10n.studysetMenuDelete,
destroys: true, destroys: true,
), ),
// Making it private or not is in Edit, with what it means one tap
// away, not a shortcut here (the developer's review of 3 Nov 2026).
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,
@ -245,12 +232,6 @@ 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);
@ -291,6 +272,12 @@ Future<void> editStudyset(
final content = await showStudysetSheet( final content = await showStudysetSheet(
context, context,
initial: studyset.content, initial: studyset.content,
// A top-level studyset's privacy, its topics' too (ADR 0019), changed
// at once, once the person agreed.
private: parentId == null ? studyset.isPrivate : null,
onPrivate: parentId == null
? (private) => setStudysetPrivacy(context, studyset, private: private)
: null,
insideAnother: parentId != null, insideAnother: parentId != null,
inheritedColorName: parentId == null ? null : colorNameOf(parentId, all), inheritedColorName: parentId == null ? null : colorNameOf(parentId, all),
inheritedLanguages: parentId == null inheritedLanguages: parentId == null

View file

@ -10,8 +10,8 @@ import 'package:material_ui/material_ui.dart';
/// readable by the instance again (ADR 0019), once the person agreed: the /// readable by the instance again (ADR 0019), once the person agreed: the
/// dialog says what changes, and that the instance's backups keep readable /// dialog says what changes, and that the instance's backups keep readable
/// copies until they expire. Where this device can't encrypt (an account /// copies until they expire. Where this device can't encrypt (an account
/// whose keys aren't here), says how to. /// whose keys aren't here), says how to. Whether it was changed.
Future<void> setStudysetPrivacy( Future<bool> setStudysetPrivacy(
BuildContext context, BuildContext context,
Studyset studyset, { Studyset studyset, {
required bool private, required bool private,
@ -21,7 +21,7 @@ Future<void> setStudysetPrivacy(
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.studysetPrivateNoKeys)), SnackBar(content: Text(l10n.studysetPrivateNoKeys)),
); );
return; return false;
} }
final title = studyset.content.title; final title = studyset.content.title;
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
@ -52,9 +52,10 @@ Future<void> setStudysetPrivacy(
), ),
); );
if (confirmed != true || !context.mounted) { if (confirmed != true || !context.mounted) {
return; return false;
} }
await applyStudysetPrivacy(context, studyset, private: private); await applyStudysetPrivacy(context, studyset, private: private);
return true;
} }
/// Makes the top-level [studyset] private or not at once, the person /// Makes the top-level [studyset] private or not at once, the person

View file

@ -35,6 +35,15 @@ Future<StudysetContent?> showStudysetSheet(
/// The studyset edited, whose files its description's pictures go with; /// The studyset edited, whose files its description's pictures go with;
/// none for a new one, which takes them once made. /// none for a new one, which takes them once made.
String? studysetId, String? studysetId,
/// Whether the top-level studyset edited is private (ADR 0019); null
/// where the sheet offers no choice: a new studyset, or a topic, whose
/// privacy is its tree's.
bool? private,
/// Makes it private or not at once, once the person agreed, rather than
/// on Save, which the content alone waits for; whether it was changed.
Future<bool> Function(bool private)? onPrivate,
}) => showAdaptiveSheet<StudysetContent>( }) => showAdaptiveSheet<StudysetContent>(
context, context,
// Swiping would discard changes without asking. // Swiping would discard changes without asking.
@ -46,6 +55,8 @@ Future<StudysetContent?> showStudysetSheet(
inheritedLanguages: inheritedLanguages, inheritedLanguages: inheritedLanguages,
inheritedLicense: inheritedLicense, inheritedLicense: inheritedLicense,
studysetId: studysetId, studysetId: studysetId,
private: private,
onPrivate: onPrivate,
), ),
); );
@ -59,6 +70,8 @@ class const _StudysetSheet({
required final StudysetLanguages inheritedLanguages, required final StudysetLanguages inheritedLanguages,
required final StudysetLicense inheritedLicense, required final StudysetLicense inheritedLicense,
final String? studysetId, final String? studysetId,
final bool? private,
final Future<bool> Function(bool private)? onPrivate,
}) extends StatefulWidget { }) extends StatefulWidget {
@override @override
State<_StudysetSheet> createState() => _StudysetSheetState(); State<_StudysetSheet> createState() => _StudysetSheetState();
@ -110,6 +123,17 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
late String? _frontLanguage = widget.initial?.frontLanguage; late String? _frontLanguage = widget.initial?.frontLanguage;
late String? _backLanguage = widget.initial?.backLanguage; late String? _backLanguage = widget.initial?.backLanguage;
/// Whether the studyset is private now, as last changed here.
late var _private = widget.private;
Future<void> _setPrivate(bool private) async {
if (await widget.onPrivate?.call(private) ?? false) {
if (mounted) {
setState(() => _private = private);
}
}
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -487,6 +511,37 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
setState(() => _backLanguage = language), setState(() => _backLanguage = language),
), ),
], ],
// Who can read it (ADR 0019), here rather than in its options
// (the developer's review of 3 Nov 2026), what it means one tap
// away.
if (_private case final private?) ...[
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: Text(
l10n.studysetSheetVisibility,
style: theme.textTheme.labelLarge,
),
),
HelpButton(
private
? l10n.studysetReadableBody
: l10n.studysetPrivateBody,
title: l10n.studysetSheetPrivate,
),
],
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
secondary: Icon(
private ? LucideIcons.lock : LucideIcons.lockOpen,
),
title: Text(l10n.studysetSheetPrivate),
value: private,
onChanged: _setPrivate,
),
],
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
), ),

View file

@ -482,6 +482,42 @@ void main() {
expect(tops, [...tops]..sort()); expect(tops, [...tops]..sort());
}); });
testWidgets('Edit makes a top-level studyset private once agreed, as its '
"options no longer do (the developer's review of 3 Nov 2026)", (
tester,
) async {
stored('japanese', 'Japanese');
gateway.canMakePrivate = true;
await showLibrary(tester);
await tester.tap(onCard('Japanese', find.byTooltip('Options')));
await tester.pumpAndSettle();
expect(find.text('Make private'), findsNothing);
await tester.tap(find.text('Edit'));
await tester.pumpAndSettle();
final private = find.widgetWithText(SwitchListTile, 'Private');
await tester.scrollUntilVisible(
private,
200,
scrollable: find.byType(Scrollable).last,
);
expect(tester.widget<SwitchListTile>(private).value, isFalse);
await tester.tap(private);
await tester.pumpAndSettle();
expect(find.text('Make “Japanese” private?'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Make private'));
await tester.pumpAndSettle();
expect(gateway.privacyChanges, [('japanese', true)]);
expect(tester.widget<SwitchListTile>(private).value, isTrue);
await tester.tap(private);
await tester.pumpAndSettle();
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(gateway.privacyChanges, [('japanese', true)], reason: 'cancelled');
expect(tester.widget<SwitchListTile>(private).value, isTrue);
});
testWidgets('moves a studyset into another from its options', ( testWidgets('moves a studyset into another from its options', (
tester, tester,
) async { ) async {

View file

@ -14,41 +14,27 @@ void main() {
for (final entry in studysetOptions(l10n, studyset)) entry.option, for (final entry in studysetOptions(l10n, studyset)) entry.option,
}; };
test('a readable top-level studyset can be made private, a private one ' test("no studyset's options make it private or non-private: that is "
'non-private', () { "done in Edit (the developer's review of 3 Nov 2026)", () {
final readable = Studyset( for (final studyset in [
id: 's', Studyset(id: 's', content: StudysetContent.titled('Diary')),
content: StudysetContent.titled('Diary'), Studyset(
); id: 's',
final private = Studyset( content: StudysetContent.titled('Diary'),
id: 's', isPrivate: true,
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),
),
), ),
); Studyset(
id: 't',
content: StudysetContent.titled('Kanji'),
parentId: 's',
isPrivate: true,
),
]) {
expect(
[for (final entry in studysetOptions(l10n, studyset)) entry.label],
isNot(anyOf(contains('Make private'), contains('Make non-private'))),
);
}
}); });
test('a locked studyset offers nothing but deleting', () { test('a locked studyset offers nothing but deleting', () {