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
4 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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue