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:
parent
0e10afcc68
commit
9cdd4859d1
8 changed files with 262 additions and 1 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue