feat(library): offer once to make every studyset from before private (ADR 0019, D4)

New studysets are private; those from before stay readable until the
person chooses. The library offers once, on this device, where it can
encrypt and some top-level studyset isn't private: Make all private, or
Not now; either answer is remembered (`private_offer` in the person's
folder) and the offer goes. Each studyset's menu changes it later.

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:49:27 +02:00
commit 9cdd4859d1
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
8 changed files with 262 additions and 1 deletions

View file

@ -77,6 +77,7 @@ import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_reposit
import 'package:halkyon_learn_flutter/src/feature/settings/data/sync_settings_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/sync_review_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/private_offer_store.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_progress_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
@ -537,6 +538,9 @@ Future<PersonDependencies> _openPersonData(
File(p.join(folder.path, 'pomodoro.json')),
),
studysetTabs: studysetTabs,
privateOffer: FilePrivateOfferStore(
File(p.join(folder.path, 'private_offer')),
),
profile: ProfileRepository(ServerpodProfileGateway(client)),
publishing: publishing,
explore: ExploreRepository(

View file

@ -18,6 +18,7 @@ import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repositor
import 'package:halkyon_learn_flutter/src/feature/publish/data/publish_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/private_offer_store.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_progress_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
@ -91,6 +92,10 @@ class PersonDependencies({
/// The tab each of the person's studysets was left on, on this device.
required final StudysetTabs studysetTabs,
/// Whether the library's offer to make every studyset private was
/// answered on this device (ADR 0019, D4).
required final PrivateOfferStore privateOffer,
/// The person's public profile, on the instance.
required final ProfileRepository profile,

View file

@ -0,0 +1,24 @@
import 'dart:io';
/// Whether the person answered the library's offer to make every studyset
/// private (ADR 0019, D4), on this device: asked once, whatever the answer.
abstract interface class PrivateOfferStore() {
/// Whether it was answered.
Future<bool> answered();
/// Notes that it was.
Future<void> answer();
}
/// [PrivateOfferStore] as a mark in the person's folder: the file is there
/// once the offer was answered.
class FilePrivateOfferStore(final File _mark) implements PrivateOfferStore {
@override
Future<bool> answered() async => _mark.existsSync();
@override
Future<void> answer() async {
await _mark.parent.create(recursive: true);
await _mark.writeAsString('');
}
}

View file

@ -489,6 +489,10 @@ class _GridState() extends State<_Grid> {
hasScrollBody: false,
child: _Message(l10n.librarySearchNoMatches),
),
if (!searching)
SliverToBoxAdapter(
child: _PrivateOffer(studysets: widget.studysets),
),
SliverPadding(
padding: const EdgeInsets.all(_spacing),
sliver: SliverGrid.builder(
@ -639,6 +643,121 @@ class _GridState() extends State<_Grid> {
}
/// What Recently deleted keeps, and for how long.
/// Offers once, on this device, to make every studyset that isn't private
/// private (ADR 0019, D4): new ones are already, and those from before
/// stay readable until the person chooses. Shown only where this device
/// can encrypt and some studyset isn't private; gone once answered.
class const _PrivateOffer({required final List<Studyset> studysets})
extends StatefulWidget {
@override
State<_PrivateOffer> createState() => _PrivateOfferState();
}
class _PrivateOfferState() extends State<_PrivateOffer> {
/// Whether it was answered; null until read.
bool? _answered;
var _busy = false;
@override
void initState() {
super.initState();
unawaited(() async {
final answered = await PersonDependencies.of(
context,
).privateOffer.answered();
if (mounted) {
setState(() => _answered = answered);
}
}());
}
List<Studyset> get _readable => [
for (final studyset in widget.studysets)
if (studyset.parentId == null &&
!studyset.isPrivate &&
!studyset.isLocked)
studyset,
];
Future<void> _answer({required bool makePrivate}) async {
final person = PersonDependencies.of(context);
setState(() => _busy = true);
try {
if (makePrivate) {
for (final studyset in _readable) {
await person.studysets.setPrivate(studyset.id, private: true);
}
}
await person.privateOffer.answer();
} finally {
if (mounted) {
setState(() {
_busy = false;
_answered = true;
});
}
}
}
@override
Widget build(BuildContext context) {
final person = PersonDependencies.of(context);
if (_answered != false ||
!person.studysets.canMakePrivate ||
_readable.isEmpty) {
return const SizedBox.shrink();
}
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
child: Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(LucideIcons.lock),
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.libraryOfferPrivateTitle,
style: theme.textTheme.titleMedium,
),
),
],
),
const SizedBox(height: 8),
Text(l10n.libraryOfferPrivateBody),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.end,
children: [
TextButton(
onPressed: _busy ? null : () => _answer(makePrivate: false),
child: Text(l10n.libraryOfferPrivateLater),
),
FilledButton(
onPressed: _busy ? null : () => _answer(makePrivate: true),
child: _busy
? const ButtonLoadingIndicator()
: Text(l10n.libraryOfferPrivateAction),
),
],
),
],
),
),
),
);
}
}
class const _DeletedNote() extends StatelessWidget {
@override
Widget build(BuildContext context) {

View file

@ -0,0 +1,92 @@
import 'package:flutter_test/flutter_test.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:material_ui/material_ui.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/memory_private_offer_store.dart';
import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart';
/// The library's one offer to make the studysets from before private (ADR
/// 0019, D4).
void main() {
late FakeStudysetGateway gateway;
late MemoryPrivateOfferStore offer;
setUp(() {
gateway = FakeStudysetGateway()..canMakePrivate = true;
offer = MemoryPrivateOfferStore();
gateway
..receiveRemoteChange(
'japanese',
encodeStudysetPayload(StudysetContent.titled('Japanese')),
)
..receiveRemoteChange(
'kanji',
encodeStudysetPayload(StudysetContent.titled('Kanji')),
parentId: 'japanese',
)
..receiveRemoteChange(
'diary',
encodeStudysetPayload(StudysetContent.titled('Diary')),
isPrivate: true,
);
});
Future<void> showLibrary(WidgetTester tester) async {
tester.view.physicalSize = const Size(1200, 1600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(studysetGateway: gateway, privateOffer: offer),
location: RoutePaths.library,
);
await tester.pump();
await tester.pump();
}
testWidgets('makes every top-level studyset that is not private private '
'at once, then never asks again', (tester) async {
await showLibrary(tester);
expect(find.text('Make your studysets private?'), findsOneWidget);
await tester.tap(find.text('Make all private'));
await tester.pump();
await tester.pump();
expect(gateway.privacyChanges, [('japanese', true)]);
expect(offer.isAnswered, isTrue);
expect(find.text('Make your studysets private?'), findsNothing);
});
testWidgets('not now leaves them as they are, and asks no more', (
tester,
) async {
await showLibrary(tester);
await tester.tap(find.text('Not now'));
await tester.pump();
await tester.pump();
expect(gateway.privacyChanges, isEmpty);
expect(offer.isAnswered, isTrue);
expect(find.text('Make your studysets private?'), findsNothing);
});
testWidgets('is never shown once answered', (tester) async {
offer.isAnswered = true;
await showLibrary(tester);
expect(find.text('Make your studysets private?'), findsNothing);
});
testWidgets("is never shown on a device without the account's keys", (
tester,
) async {
gateway.canMakePrivate = false;
await showLibrary(tester);
expect(find.text('Make your studysets private?'), findsNothing);
});
}

View file

@ -70,7 +70,7 @@ class FakeStudysetGateway() implements StudysetGateway {
final privacyChanges = <(String, bool)>[];
@override
bool canMakePrivate = true;
bool canMakePrivate = false;
@override
Future<int> setPrivate(String id, {required bool private}) async {

View file

@ -0,0 +1,13 @@
import 'package:halkyon_learn_flutter/src/feature/studyset/data/private_offer_store.dart';
/// [PrivateOfferStore] in memory.
class MemoryPrivateOfferStore() implements PrivateOfferStore {
/// Whether the offer was answered.
bool isAnswered = false;
@override
Future<bool> answered() async => isAnswered;
@override
Future<void> answer() async => isAnswered = true;
}

View file

@ -69,6 +69,7 @@ import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_gateway
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.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/private_offer_store.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_progress_repository.dart';
@ -106,6 +107,7 @@ import 'fake_youtube_videos.dart';
import 'memory_asset_file_cache.dart';
import 'memory_device_settings_store.dart';
import 'memory_pomodoro_run_store.dart';
import 'memory_private_offer_store.dart';
import 'memory_published_store.dart';
import 'memory_studyset_tab_store.dart';
@ -158,6 +160,7 @@ Dependencies testDependencies({
PublishGateway? publishGateway,
PublishedStore? publishedStore,
StudysetTabStore? studysetTabStore,
PrivateOfferStore? privateOffer,
ExploreGateway? exploreGateway,
FeedbackWriteGateway? feedbackWriteGateway,
FeedbackServerGateway? feedbackServerGateway,
@ -321,6 +324,7 @@ Dependencies testDependencies({
pdfDeletions: pdfDeletions,
pomodoroRuns: pomodoroRuns ?? MemoryPomodoroRunStore(),
studysetTabs: studysetTabs,
privateOffer: privateOffer ?? MemoryPrivateOfferStore(),
// The picture as it is: drawing it anew is real work a widget
// test's fake clock can't wait for (avatar_image_test.dart has it).
profile: ProfileRepository(