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:
parent
96b1a0fc64
commit
a7904bac05
10 changed files with 156 additions and 61 deletions
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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."}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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`).
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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', () {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue