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
4 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) {