feat(learn): search ranks titles first; privacy changes show progress
- The Library's search lists titles starting with what is typed first, then titles holding it as a word, then secondary titles, rather than by the shelves' recency. - The offer to make every studyset private is gone: each studyset's lock already says and changes it, and new ones are private. - Making a studyset private or not from its sheet flips the switch as soon as the person agrees and says that everything in it is being written anew, rather than standing still for a minute. - Jellyfin's password can be shown while typed. Whats-New: Searching the library lists title matches first; changing a studyset's privacy shows its progress; Jellyfin's password can be shown. Whats-New-De: Die Suche in der Bibliothek zeigt Treffer im Titel zuerst; das Ändern der Privatsphäre eines Lernsets zeigt den Fortschritt; das Jellyfin-Passwort lässt sich anzeigen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
29f900992b
commit
1cc27ef0d6
17 changed files with 276 additions and 235 deletions
File diff suppressed because one or more lines are too long
|
|
@ -2077,10 +2077,6 @@
|
|||
"@cardMovePrivateBody": {"placeholders": {"title": {"type": "String"}}},
|
||||
"cardMovePrivateAction": "Privat machen und verschieben",
|
||||
"privacyKeysMissing": "Melde dich auf diesem Gerät mit deinem Passwort an: Was du hier hinzufügst oder änderst, wäre privat.",
|
||||
"libraryOfferPrivateTitle": "Deine Lernsets privat machen?",
|
||||
"libraryOfferPrivateBody": "Neue Lernsets sind privat: Ende-zu-Ende-verschlüsselt, sodass nur deine Geräte sie lesen können. Die, die du vorher gemacht hast, bleiben für die Instanz lesbar, bis du sie auch privat machst. Kopien in den Sicherungen der Instanz bleiben lesbar, bis diese ablaufen.",
|
||||
"libraryOfferPrivateAction": "Alle privat machen",
|
||||
"libraryOfferPrivateLater": "Jetzt nicht",
|
||||
"exportReadableNote": "Das Archiv ist nicht verschlüsselt: Wer die Datei hat, kann es lesen.",
|
||||
"studysetVisibilityReadable": "Nicht privat, nicht veröffentlicht",
|
||||
"studysetShareReadable": "Teilen: nicht privat",
|
||||
|
|
@ -2202,5 +2198,7 @@
|
|||
"markdownToShareToday": "heute bis {percent} %",
|
||||
"markdownDeadlinesProgress": "{furthest} %/{target} % · {when}",
|
||||
"readerShowSource": "Quelltext zeigen",
|
||||
"readerShowFormatted": "Formatiert zeigen"
|
||||
"readerShowFormatted": "Formatiert zeigen",
|
||||
"studysetSheetMakingPrivate": "Alles darin wird verschlüsselt … Das kann eine Minute dauern; du kannst das hier solange schließen.",
|
||||
"studysetSheetMakingReadable": "Alles darin wird lesbar gemacht … Das kann eine Minute dauern; du kannst das hier solange schließen."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2490,10 +2490,6 @@
|
|||
"@cardMovePrivateBody": {"placeholders": {"title": {"type": "String"}}},
|
||||
"cardMovePrivateAction": "Make private and move",
|
||||
"privacyKeysMissing": "Sign in with your password on this device: what you add or change here would be private.",
|
||||
"libraryOfferPrivateTitle": "Make your studysets private?",
|
||||
"libraryOfferPrivateBody": "New studysets are private: end-to-end encrypted, so only your devices can read them. Those you made before stay readable by the instance until you make them private too. Copies in the instance's backups stay readable until those expire.",
|
||||
"libraryOfferPrivateAction": "Make all private",
|
||||
"libraryOfferPrivateLater": "Not now",
|
||||
"exportReadableNote": "The archive isn't encrypted: whoever has the file can read it.",
|
||||
"studysetVisibilityReadable": "Not private, not published",
|
||||
"studysetShareReadable": "Share: not private",
|
||||
|
|
@ -2730,5 +2726,9 @@
|
|||
"readerShowSource": "Show the source text",
|
||||
"@readerShowSource": {"description": "Tooltip: shows a Markdown file as written, rather than formatted"},
|
||||
"readerShowFormatted": "Show it formatted",
|
||||
"@readerShowFormatted": {"description": "Tooltip: shows a Markdown file formatted again, after its source text"}
|
||||
"@readerShowFormatted": {"description": "Tooltip: shows a Markdown file formatted again, after its source text"},
|
||||
"studysetSheetMakingPrivate": "Encrypting everything in it… This can take a minute; you can close this meanwhile.",
|
||||
"@studysetSheetMakingPrivate": {"description": "Below the Private switch of a studyset's sheet while it is being made private"},
|
||||
"studysetSheetMakingReadable": "Making everything in it readable… This can take a minute; you can close this meanwhile.",
|
||||
"@studysetSheetMakingReadable": {"description": "Below the Private switch of a studyset's sheet while it is being made readable by the instance"}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12242,30 +12242,6 @@ abstract class AppLocalizations {
|
|||
/// **'Sign in with your password on this device: what you add or change here would be private.'**
|
||||
String get privacyKeysMissing;
|
||||
|
||||
/// No description provided for @libraryOfferPrivateTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Make your studysets private?'**
|
||||
String get libraryOfferPrivateTitle;
|
||||
|
||||
/// No description provided for @libraryOfferPrivateBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'New studysets are private: end-to-end encrypted, so only your devices can read them. Those you made before stay readable by the instance until you make them private too. Copies in the instance\'s backups stay readable until those expire.'**
|
||||
String get libraryOfferPrivateBody;
|
||||
|
||||
/// No description provided for @libraryOfferPrivateAction.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Make all private'**
|
||||
String get libraryOfferPrivateAction;
|
||||
|
||||
/// No description provided for @libraryOfferPrivateLater.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Not now'**
|
||||
String get libraryOfferPrivateLater;
|
||||
|
||||
/// No description provided for @exportReadableNote.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -12997,6 +12973,18 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'Show it formatted'**
|
||||
String get readerShowFormatted;
|
||||
|
||||
/// Below the Private switch of a studyset's sheet while it is being made private
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Encrypting everything in it… This can take a minute; you can close this meanwhile.'**
|
||||
String get studysetSheetMakingPrivate;
|
||||
|
||||
/// Below the Private switch of a studyset's sheet while it is being made readable by the instance
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Making everything in it readable… This can take a minute; you can close this meanwhile.'**
|
||||
String get studysetSheetMakingReadable;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -7888,19 +7888,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String get privacyKeysMissing =>
|
||||
'Melde dich auf diesem Gerät mit deinem Passwort an: Was du hier hinzufügst oder änderst, wäre privat.';
|
||||
|
||||
@override
|
||||
String get libraryOfferPrivateTitle => 'Deine Lernsets privat machen?';
|
||||
|
||||
@override
|
||||
String get libraryOfferPrivateBody =>
|
||||
'Neue Lernsets sind privat: Ende-zu-Ende-verschlüsselt, sodass nur deine Geräte sie lesen können. Die, die du vorher gemacht hast, bleiben für die Instanz lesbar, bis du sie auch privat machst. Kopien in den Sicherungen der Instanz bleiben lesbar, bis diese ablaufen.';
|
||||
|
||||
@override
|
||||
String get libraryOfferPrivateAction => 'Alle privat machen';
|
||||
|
||||
@override
|
||||
String get libraryOfferPrivateLater => 'Jetzt nicht';
|
||||
|
||||
@override
|
||||
String get exportReadableNote =>
|
||||
'Das Archiv ist nicht verschlüsselt: Wer die Datei hat, kann es lesen.';
|
||||
|
|
@ -8323,4 +8310,12 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get readerShowFormatted => 'Formatiert zeigen';
|
||||
|
||||
@override
|
||||
String get studysetSheetMakingPrivate =>
|
||||
'Alles darin wird verschlüsselt … Das kann eine Minute dauern; du kannst das hier solange schließen.';
|
||||
|
||||
@override
|
||||
String get studysetSheetMakingReadable =>
|
||||
'Alles darin wird lesbar gemacht … Das kann eine Minute dauern; du kannst das hier solange schließen.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7816,19 +7816,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get privacyKeysMissing =>
|
||||
'Sign in with your password on this device: what you add or change here would be private.';
|
||||
|
||||
@override
|
||||
String get libraryOfferPrivateTitle => 'Make your studysets private?';
|
||||
|
||||
@override
|
||||
String get libraryOfferPrivateBody =>
|
||||
'New studysets are private: end-to-end encrypted, so only your devices can read them. Those you made before stay readable by the instance until you make them private too. Copies in the instance\'s backups stay readable until those expire.';
|
||||
|
||||
@override
|
||||
String get libraryOfferPrivateAction => 'Make all private';
|
||||
|
||||
@override
|
||||
String get libraryOfferPrivateLater => 'Not now';
|
||||
|
||||
@override
|
||||
String get exportReadableNote =>
|
||||
'The archive isn\'t encrypted: whoever has the file can read it.';
|
||||
|
|
@ -8249,6 +8236,14 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get readerShowFormatted => 'Show it formatted';
|
||||
|
||||
@override
|
||||
String get studysetSheetMakingPrivate =>
|
||||
'Encrypting everything in it… This can take a minute; you can close this meanwhile.';
|
||||
|
||||
@override
|
||||
String get studysetSheetMakingReadable =>
|
||||
'Making everything in it readable… This can take a minute; you can close this meanwhile.';
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
|
|||
|
|
@ -80,7 +80,6 @@ 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';
|
||||
|
|
@ -635,9 +634,6 @@ Future<PersonDependencies> _openPersonData(
|
|||
File(p.join(folder.path, 'pomodoro.json')),
|
||||
),
|
||||
studysetTabs: studysetTabs,
|
||||
privateOffer: FilePrivateOfferStore(
|
||||
File(p.join(folder.path, 'private_offer')),
|
||||
),
|
||||
publishing: publishing,
|
||||
explore: ExploreRepository(
|
||||
ServerpodExploreGateway(client),
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_run_sto
|
|||
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';
|
||||
|
|
@ -106,10 +105,6 @@ 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,
|
||||
|
||||
/// Publishing the person's studysets, on the instance.
|
||||
required final PublishRepository publishing,
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_gateway.dart';
|
||||
|
|
@ -40,6 +41,9 @@ class _JellyfinConnectFormState() extends State<JellyfinConnectForm> {
|
|||
final _address = TextEditingController();
|
||||
final _user = TextEditingController();
|
||||
final _password = TextEditingController();
|
||||
|
||||
/// Whether the password is shown as typed.
|
||||
var _passwordShown = false;
|
||||
final _passwordFocus = FocusNode();
|
||||
JellyfinServer? _server;
|
||||
List<JellyfinPublicUser> _publicUsers = const [];
|
||||
|
|
@ -289,13 +293,25 @@ class _JellyfinConnectFormState() extends State<JellyfinConnectForm> {
|
|||
focusNode: _passwordFocus,
|
||||
maxLength: TextLimits.phrase,
|
||||
buildCounter: nearLimitCounter,
|
||||
obscureText: true,
|
||||
obscureText: !_passwordShown,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
autofillHints: const [AutofillHints.password],
|
||||
textInputAction: TextInputAction.go,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.jellyfinPassword,
|
||||
helperText: l10n.jellyfinPasswordNone,
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
tooltip: _passwordShown
|
||||
? HalkyonAppLocalizations.of(context).passwordHide
|
||||
: HalkyonAppLocalizations.of(context).passwordShow,
|
||||
icon: Icon(
|
||||
_passwordShown ? LucideIcons.eyeOff : LucideIcons.eye,
|
||||
),
|
||||
onPressed: () =>
|
||||
setState(() => _passwordShown = !_passwordShown),
|
||||
),
|
||||
),
|
||||
onSubmitted: _busy ? null : (_) => _signIn(),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import 'package:halkyon_learn_flutter/src/feature/study/widget/start_studying.da
|
|||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/library_shelves.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/subject.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/delete_studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/deleted_studyset_card.dart';
|
||||
|
|
@ -507,8 +506,35 @@ class _GridState() extends State<_Grid> {
|
|||
);
|
||||
|
||||
static bool _matches(Studyset studyset, String query) =>
|
||||
studyset.content.title.toLowerCase().contains(query) ||
|
||||
(studyset.content.secondaryTitle?.toLowerCase().contains(query) ?? false);
|
||||
_rankOf(studyset, query) != null;
|
||||
|
||||
/// How well [studyset] matches [query], lower first: its title starting
|
||||
/// with it, a word of its title starting with it, its title holding it,
|
||||
/// then its secondary title alike; null for no match.
|
||||
static int? _rankOf(Studyset studyset, String query) {
|
||||
int? rankIn(String? text) {
|
||||
final lower = text?.toLowerCase();
|
||||
if (lower == null || !lower.contains(query)) {
|
||||
return null;
|
||||
}
|
||||
if (lower.startsWith(query)) {
|
||||
return 0;
|
||||
}
|
||||
return lower.contains(
|
||||
RegExp(r'(^|[^\p{L}\p{N}])' + RegExp.escape(query), unicode: true),
|
||||
)
|
||||
? 1
|
||||
: 2;
|
||||
}
|
||||
|
||||
if (rankIn(studyset.content.title) case final rank?) {
|
||||
return rank;
|
||||
}
|
||||
if (rankIn(studyset.content.secondaryTitle) case final rank?) {
|
||||
return 3 + rank;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The studysets out on Explore, as last known.
|
||||
var _published = const <String>{};
|
||||
|
|
@ -540,6 +566,26 @@ class _GridState() extends State<_Grid> {
|
|||
progress: widget.progress,
|
||||
now: clock.now().toUtc(),
|
||||
);
|
||||
// A search lists its matches by how well they match, in one grid, the
|
||||
// shelves' order within the same rank.
|
||||
final ranked = searching
|
||||
? ([
|
||||
for (final (index, studyset) in [
|
||||
for (final listed in shelves.values) ...listed,
|
||||
].indexed)
|
||||
(
|
||||
rank: _rankOf(studyset, query)!,
|
||||
index: index,
|
||||
studyset: studyset,
|
||||
),
|
||||
]..sort(
|
||||
(a, b) => a.rank != b.rank
|
||||
? a.rank.compareTo(b.rank)
|
||||
: a.index.compareTo(b.index),
|
||||
))
|
||||
.map((match) => match.studyset)
|
||||
.toList()
|
||||
: const <Studyset>[];
|
||||
final archived = [
|
||||
for (final studyset in widget.studysets)
|
||||
if (studyset.isArchived && listed(studyset)) studyset,
|
||||
|
|
@ -567,30 +613,42 @@ class _GridState() extends State<_Grid> {
|
|||
hasScrollBody: false,
|
||||
child: _Message(l10n.librarySearchNoMatches),
|
||||
),
|
||||
if (!searching)
|
||||
SliverToBoxAdapter(
|
||||
child: _PrivateOffer(studysets: widget.studysets),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: _SubjectDropdown(subjects: inUse, selected: subject),
|
||||
),
|
||||
for (final MapEntry(key: shelf, value: listed)
|
||||
in shelves.entries) ...[
|
||||
SliverToBoxAdapter(child: _ShelfHeading(shelf)),
|
||||
if (searching && ranked.isNotEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
_spacing,
|
||||
0,
|
||||
_spacing,
|
||||
_spacing,
|
||||
_spacing,
|
||||
),
|
||||
sliver: SliverGrid.builder(
|
||||
gridDelegate: _layout,
|
||||
itemCount: listed.length,
|
||||
itemBuilder: (context, index) => _card(context, listed[index]),
|
||||
itemCount: ranked.length,
|
||||
itemBuilder: (context, index) => _card(context, ranked[index]),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (!searching)
|
||||
for (final MapEntry(key: shelf, value: listed)
|
||||
in shelves.entries) ...[
|
||||
SliverToBoxAdapter(child: _ShelfHeading(shelf)),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
_spacing,
|
||||
0,
|
||||
_spacing,
|
||||
_spacing,
|
||||
),
|
||||
sliver: SliverGrid.builder(
|
||||
gridDelegate: _layout,
|
||||
itemCount: listed.length,
|
||||
itemBuilder: (context, index) =>
|
||||
_card(context, listed[index]),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (archived.isNotEmpty) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: _SectionHeader(
|
||||
|
|
@ -742,142 +800,6 @@ 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 person = PersonDependencies.of(context);
|
||||
// Answered on this device, or on another of the person's (the
|
||||
// reading of 22 Oct: the answer follows them).
|
||||
final answered =
|
||||
await person.privateOffer.answered() ||
|
||||
(await person.settings.read()).privateOfferAnswered;
|
||||
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);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
if (makePrivate) {
|
||||
// Every top-level studyset, those waiting in Recently deleted and
|
||||
// those whose parent is gone among them (the reading of 22 Oct).
|
||||
var left = 0;
|
||||
for (final studyset in topLevel(await person.studysets.withDeleted())) {
|
||||
if (!studyset.isPrivate && !studyset.isLocked) {
|
||||
left += await person.studysets.setPrivate(
|
||||
studyset.id,
|
||||
private: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (left > 0) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.studysetPrivateSomeLocked)),
|
||||
);
|
||||
}
|
||||
}
|
||||
await person.privateOffer.answer();
|
||||
await person.settings.update(
|
||||
(settings) => settings.copyWith(privateOfferAnswered: true),
|
||||
);
|
||||
} 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) {
|
||||
|
|
|
|||
|
|
@ -275,7 +275,12 @@ Future<void> editStudyset(
|
|||
// at once, once the person agreed.
|
||||
private: parentId == null ? studyset.isPrivate : null,
|
||||
onPrivate: parentId == null
|
||||
? (private) => setStudysetPrivacy(context, studyset, private: private)
|
||||
? (private, agreed) => setStudysetPrivacy(
|
||||
context,
|
||||
studyset,
|
||||
private: private,
|
||||
onAgreed: agreed,
|
||||
)
|
||||
: null,
|
||||
insideAnother: parentId != null,
|
||||
inheritedColorName: parentId == null ? null : colorNameOf(parentId, all),
|
||||
|
|
|
|||
|
|
@ -10,11 +10,13 @@ import 'package:material_ui/material_ui.dart';
|
|||
/// readable by the instance again (ADR 0019), once the person agreed: the
|
||||
/// dialog says what changes, and that the instance's backups keep readable
|
||||
/// copies until they expire. Where this device can't encrypt (an account
|
||||
/// whose keys aren't here), says how to. Whether it was changed.
|
||||
/// whose keys aren't here), says how to. Calls [onAgreed] as the person
|
||||
/// agrees, before the rewrite, which takes a while. Whether it was changed.
|
||||
Future<bool> setStudysetPrivacy(
|
||||
BuildContext context,
|
||||
Studyset studyset, {
|
||||
required bool private,
|
||||
VoidCallback? onAgreed,
|
||||
}) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
if (private && !PersonDependencies.of(context).studysets.canMakePrivate) {
|
||||
|
|
@ -54,6 +56,7 @@ Future<bool> setStudysetPrivacy(
|
|||
if (confirmed != true || !context.mounted) {
|
||||
return false;
|
||||
}
|
||||
onAgreed?.call();
|
||||
await applyStudysetPrivacy(context, studyset, private: private);
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,8 +44,9 @@ Future<StudysetContent?> showStudysetSheet(
|
|||
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,
|
||||
/// on Save, which the content alone waits for, calling `agreed` as the
|
||||
/// person agrees; whether it was changed.
|
||||
Future<bool> Function(bool private, VoidCallback agreed)? onPrivate,
|
||||
}) => showAdaptiveSheet<StudysetContent>(
|
||||
context,
|
||||
// Swiping would discard changes without asking.
|
||||
|
|
@ -73,7 +74,7 @@ class const _StudysetSheet({
|
|||
required final StudysetLicense inheritedLicense,
|
||||
final String? studysetId,
|
||||
final bool? private,
|
||||
final Future<bool> Function(bool private)? onPrivate,
|
||||
final Future<bool> Function(bool private, VoidCallback agreed)? onPrivate,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<_StudysetSheet> createState() => _StudysetSheetState();
|
||||
|
|
@ -129,13 +130,40 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
|
|||
late String? _frontLanguage = widget.initial?.frontLanguage;
|
||||
late String? _backLanguage = widget.initial?.backLanguage;
|
||||
|
||||
/// Whether the studyset is private now, as last changed here.
|
||||
/// Whether the studyset is private now, as last changed here: as soon
|
||||
/// as the person agreed, while everything in it is written anew.
|
||||
late var _private = widget.private;
|
||||
|
||||
/// Whether its privacy is being changed: every row of its tree written
|
||||
/// anew, a minute for thousands of cards (the developer's review, 10 Nov
|
||||
/// 2026: the switch stood still meanwhile, as if nothing happened).
|
||||
var _changing = false;
|
||||
|
||||
Future<void> _setPrivate(bool private) async {
|
||||
if (await widget.onPrivate?.call(private) ?? false) {
|
||||
final before = _private;
|
||||
try {
|
||||
final changed =
|
||||
await widget.onPrivate?.call(private, () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_private = private;
|
||||
_changing = true;
|
||||
});
|
||||
}
|
||||
}) ??
|
||||
false;
|
||||
if (mounted && !changed) {
|
||||
setState(() => _private = before);
|
||||
}
|
||||
} on Object {
|
||||
// Cut short, as it stood: the rewrite said why.
|
||||
if (mounted) {
|
||||
setState(() => _private = private);
|
||||
setState(() => _private = before);
|
||||
}
|
||||
rethrow;
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _changing = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -573,8 +601,23 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
|
|||
),
|
||||
title: Text(l10n.studysetSheetPrivate),
|
||||
value: private,
|
||||
onChanged: _setPrivate,
|
||||
onChanged: _changing ? null : _setPrivate,
|
||||
),
|
||||
if (_changing)
|
||||
Row(
|
||||
children: [
|
||||
const ButtonLoadingIndicator(),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
private
|
||||
? l10n.studysetSheetMakingPrivate
|
||||
: l10n.studysetSheetMakingReadable,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -104,6 +104,16 @@ void main() {
|
|||
expect(find.text('Living room'), findsOneWidget);
|
||||
await tester.enterText(find.widgetWithText(TextField, 'User name'), 'mika');
|
||||
await tester.enterText(find.widgetWithText(TextField, 'Password'), 'wrong');
|
||||
bool hidden() => tester
|
||||
.widget<TextField>(find.widgetWithText(TextField, 'Password'))
|
||||
.obscureText;
|
||||
expect(hidden(), isTrue);
|
||||
await tester.tap(find.byTooltip('Show password'));
|
||||
await tester.pump();
|
||||
expect(hidden(), isFalse);
|
||||
await tester.tap(find.byTooltip('Hide password'));
|
||||
await tester.pump();
|
||||
expect(hidden(), isTrue);
|
||||
await tester.tap(find.text('Sign in'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('The user name or password is wrong.'), findsOneWidget);
|
||||
|
|
|
|||
|
|
@ -683,6 +683,44 @@ void main() {
|
|||
expect(find.text('Kanji'), findsNothing, reason: 'the archive is closed');
|
||||
});
|
||||
|
||||
testWidgets('a search lists titles starting with what is typed first, then '
|
||||
'those holding it as a word, then secondary titles, whatever was changed '
|
||||
'last', (tester) async {
|
||||
stored(
|
||||
'tooling',
|
||||
'Rust',
|
||||
secondaryTitle: 'Python tooling, from the docs',
|
||||
updatedAt: DateTime.utc(2026, 11, 9),
|
||||
);
|
||||
stored(
|
||||
'learning',
|
||||
'Learning Python',
|
||||
updatedAt: DateTime.utc(2026, 11, 8),
|
||||
);
|
||||
stored('python', 'Python', updatedAt: DateTime.utc(2026, 1, 2));
|
||||
await showLibrary(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('Search'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(TextField), 'python');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// In reading order: row by row, each from the start.
|
||||
final places = [
|
||||
for (final title in ['Python', 'Learning Python', 'Rust'])
|
||||
tester.getTopLeft(find.text(title)),
|
||||
];
|
||||
for (final (before, after) in [
|
||||
(places[0], places[1]),
|
||||
(places[1], places[2]),
|
||||
]) {
|
||||
expect(
|
||||
before.dy < after.dy || (before.dy == after.dy && before.dx < after.dx),
|
||||
isTrue,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('says when no studyset matches the search', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
await showLibrary(tester);
|
||||
|
|
@ -748,11 +786,45 @@ void main() {
|
|||
expect(gateway.privacyChanges, [('japanese', true)]);
|
||||
expect(tester.widget<SwitchListTile>(private).value, isTrue);
|
||||
|
||||
// Made readable again while that takes a while: the switch follows at
|
||||
// once, says what goes on, and takes no other change meanwhile.
|
||||
final hold = gateway.holdPrivacy = Completer<void>();
|
||||
await tester.tap(private);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Make non-private'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(tester.widget<SwitchListTile>(private).value, isFalse);
|
||||
expect(tester.widget<SwitchListTile>(private).onChanged, isNull);
|
||||
expect(
|
||||
find.textContaining('Making everything in it readable'),
|
||||
findsOneWidget,
|
||||
);
|
||||
hold.complete();
|
||||
gateway.holdPrivacy = null;
|
||||
await tester.pumpAndSettle();
|
||||
expect(gateway.privacyChanges, [('japanese', true), ('japanese', false)]);
|
||||
expect(
|
||||
find.textContaining('Making everything in it readable'),
|
||||
findsNothing,
|
||||
);
|
||||
expect(tester.widget<SwitchListTile>(private).value, isFalse);
|
||||
|
||||
await tester.tap(private);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Make private'));
|
||||
await tester.pumpAndSettle();
|
||||
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(gateway.privacyChanges, [
|
||||
('japanese', true),
|
||||
('japanese', false),
|
||||
('japanese', true),
|
||||
], reason: 'cancelled');
|
||||
expect(tester.widget<SwitchListTile>(private).value, isTrue);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -75,6 +75,10 @@ class FakeStudysetGateway() implements StudysetGateway {
|
|||
/// Every change of privacy, in order, as (id, private).
|
||||
final privacyChanges = <(String, bool)>[];
|
||||
|
||||
/// While set, making a tree private or readable waits for it, as a
|
||||
/// rewrite of thousands of rows does.
|
||||
Completer<void>? holdPrivacy;
|
||||
|
||||
@override
|
||||
bool canMakePrivate = false;
|
||||
|
||||
|
|
@ -100,6 +104,9 @@ class FakeStudysetGateway() implements StudysetGateway {
|
|||
|
||||
@override
|
||||
Future<int> setPrivate(String id, {required bool private}) async {
|
||||
if (holdPrivacy case final hold?) {
|
||||
await hold.future;
|
||||
}
|
||||
final tree = {id};
|
||||
var grew = true;
|
||||
while (grew) {
|
||||
|
|
|
|||
|
|
@ -70,7 +70,6 @@ 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';
|
||||
|
|
@ -109,7 +108,6 @@ 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';
|
||||
|
||||
|
|
@ -163,7 +161,6 @@ Dependencies testDependencies({
|
|||
PublishGateway? publishGateway,
|
||||
PublishedStore? publishedStore,
|
||||
StudysetTabStore? studysetTabStore,
|
||||
PrivateOfferStore? privateOffer,
|
||||
ExploreGateway? exploreGateway,
|
||||
FeedbackWriteGateway? feedbackWriteGateway,
|
||||
FeedbackServerGateway? feedbackServerGateway,
|
||||
|
|
@ -382,7 +379,6 @@ Dependencies testDependencies({
|
|||
conversationDeletions: conversationDeletions,
|
||||
pomodoroRuns: pomodoroRuns ?? MemoryPomodoroRunStore(),
|
||||
studysetTabs: studysetTabs,
|
||||
privateOffer: privateOffer ?? MemoryPrivateOfferStore(),
|
||||
publishing: PublishRepository(
|
||||
publishGateway ?? FakePublishGateway(),
|
||||
publishedStore ?? MemoryPublishedStore(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue