fix(privacy): what the app shows and adds keeps private studysets private (ADR 0019)
The reading of 22 Oct, on the device's side. Private cards never move into a readable studyset: the card browser asks to make it private first. Locked studysets are left out of the move sheets and the destinations words are added to. A studyset whose key isn't on this device says to sign in with the password here, not that it is lost, and can still be archived; creating one there says why it can't. The marker shows the lock for a private studyset published before, and a shield with a warning while something in it is still readable or unsealed, with Finish in its dialog. The library's offer covers every top-level studyset, Recently deleted and orphaned ones too, says what couldn't be sealed, and its answer follows the person in their settings (schema 11). The full-window editors carry the studyset they were opened from, and an account with keys adds no file where none is known (a new studyset's description takes pictures once it is made). A file no live row names is never uploaded, and deleting a row clears what it owed. Sealed rows are indexed by key and hash. A suggestion from a private copy uploads readable copies of its files for the author; accepting swaps whole `asset:` values only. Docs: ADR 0019's "The reading (22 Oct 2026)" with the decisions, what changed and the residual risks; "As built" and Learn's AGENTS.md brought up to date. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
88e79155bc
commit
46a47d0975
29 changed files with 625 additions and 73 deletions
|
|
@ -1180,6 +1180,7 @@
|
||||||
"fileNotAnImage": "Diese Datei ist kein Bild, das die App anzeigen kann.",
|
"fileNotAnImage": "Diese Datei ist kein Bild, das die App anzeigen kann.",
|
||||||
"fileNotASound": "Diese Datei ist keine Audiodatei, die die App abspielen kann.",
|
"fileNotASound": "Diese Datei ist keine Audiodatei, die die App abspielen kann.",
|
||||||
"fileTooLarge": "Diese Datei ist größer als {megabytes} MB.",
|
"fileTooLarge": "Diese Datei ist größer als {megabytes} MB.",
|
||||||
|
"fileAfterCreate": "Bilder und Töne lassen sich hinzufügen, sobald das Lernset erstellt ist.",
|
||||||
"fileNotKept": "Die Datei konnte nicht auf diesem Gerät gespeichert werden.",
|
"fileNotKept": "Die Datei konnte nicht auf diesem Gerät gespeichert werden.",
|
||||||
"fileRemoveImage": "Bild entfernen",
|
"fileRemoveImage": "Bild entfernen",
|
||||||
"fileRemoveSound": "Audio entfernen",
|
"fileRemoveSound": "Audio entfernen",
|
||||||
|
|
@ -2072,9 +2073,18 @@
|
||||||
"studysetReadableBody": "Alles darin wird entschlüsselt und neu synchronisiert, sodass die Instanz es lesen kann: Du kannst es veröffentlichen, und Claude kann es über den Connector lesen.",
|
"studysetReadableBody": "Alles darin wird entschlüsselt und neu synchronisiert, sodass die Instanz es lesen kann: Du kannst es veröffentlichen, und Claude kann es über den Connector lesen.",
|
||||||
"studysetPrivateNoKeys": "Melde dich auf diesem Gerät mit deinem Passwort an, um Lernsets privat zu machen.",
|
"studysetPrivateNoKeys": "Melde dich auf diesem Gerät mit deinem Passwort an, um Lernsets privat zu machen.",
|
||||||
"studysetPrivateSomeLocked": "Manches darin ließ sich auf diesem Gerät nicht öffnen und blieb, wie es war.",
|
"studysetPrivateSomeLocked": "Manches darin ließ sich auf diesem Gerät nicht öffnen und blieb, wie es war.",
|
||||||
"studysetPrivateLabel": "Privat",
|
|
||||||
"studysetLockedTitle": "Gesperrt",
|
"studysetLockedTitle": "Gesperrt",
|
||||||
"studysetLockedBody": "Dieses Lernset ist privat, und sein Schlüssel öffnet sich auf diesem Gerät nicht: Es wurde mit Schlüsseln verschlüsselt, die dein Konto nicht mehr hat. Es lässt sich nur noch löschen.",
|
"studysetLockedBody": "Dieses Lernset ist privat, und sein Schlüssel öffnet sich auf diesem Gerät nicht: Es wurde mit Schlüsseln verschlüsselt, die dein Konto nicht mehr hat. Es lässt sich nur noch löschen.",
|
||||||
|
"studysetVisibilityPartly": "Privat, teils lesbar",
|
||||||
|
"studysetPrivacyPartlyBody": "Manches darin kann die Instanz noch lesen: was ein Gerät schrieb, bevor es davon wusste, oder Dateien, die noch nicht umgezogen sind. Es wird versiegelt, sobald ein Gerät mit deinen Schlüsseln es kann, oder jetzt mit Abschließen.",
|
||||||
|
"studysetPrivacyFinish": "Abschließen",
|
||||||
|
"studysetLockedNoKeysBody": "Dieses Lernset ist privat, und sein Schlüssel ist nicht auf diesem Gerät. Melde dich auf diesem Gerät mit deinem Passwort an, um es zu öffnen.",
|
||||||
|
"cardMovePrivateTitle": "„{title}“ privat machen?",
|
||||||
|
"@cardMovePrivateTitle": {"placeholders": {"title": {"type": "String"}}},
|
||||||
|
"cardMovePrivateBody": "Diese Karteikarten sind privat. „{title}“ kann die Instanz lesen, also können sie erst dorthin, wenn es auch privat ist.",
|
||||||
|
"@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?",
|
"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.",
|
"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",
|
"libraryOfferPrivateAction": "Alle privat machen",
|
||||||
|
|
|
||||||
|
|
@ -1337,6 +1337,7 @@
|
||||||
"megabytes": {"type": "int"}
|
"megabytes": {"type": "int"}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"fileAfterCreate": "Pictures and sounds can be added once the studyset is made.",
|
||||||
"fileNotKept": "The file couldn't be kept on this device.",
|
"fileNotKept": "The file couldn't be kept on this device.",
|
||||||
"fileRemoveImage": "Remove image",
|
"fileRemoveImage": "Remove image",
|
||||||
"fileRemoveSound": "Remove sound",
|
"fileRemoveSound": "Remove sound",
|
||||||
|
|
@ -2438,9 +2439,18 @@
|
||||||
"studysetReadableBody": "Everything in it is decrypted and synced again, so the instance can read it: you can publish it, and Claude can read it through the connector.",
|
"studysetReadableBody": "Everything in it is decrypted and synced again, so the instance can read it: you can publish it, and Claude can read it through the connector.",
|
||||||
"studysetPrivateNoKeys": "Sign in with your password on this device to make studysets private.",
|
"studysetPrivateNoKeys": "Sign in with your password on this device to make studysets private.",
|
||||||
"studysetPrivateSomeLocked": "Some of it couldn't be opened on this device and stayed as it was.",
|
"studysetPrivateSomeLocked": "Some of it couldn't be opened on this device and stayed as it was.",
|
||||||
"studysetPrivateLabel": "Private",
|
|
||||||
"studysetLockedTitle": "Locked",
|
"studysetLockedTitle": "Locked",
|
||||||
"studysetLockedBody": "This studyset is private, and its key doesn't open on this device: it was encrypted with keys your account no longer has. It can only be deleted.",
|
"studysetLockedBody": "This studyset is private, and its key doesn't open on this device: it was encrypted with keys your account no longer has. It can only be deleted.",
|
||||||
|
"studysetVisibilityPartly": "Private, partly readable",
|
||||||
|
"studysetPrivacyPartlyBody": "Some of it is still readable by the instance: what a device wrote before it heard, or files not moved yet. It is sealed as soon as a device with your keys can, or now with Finish.",
|
||||||
|
"studysetPrivacyFinish": "Finish",
|
||||||
|
"studysetLockedNoKeysBody": "This studyset is private, and its key isn't on this device. Sign in with your password on this device to open it.",
|
||||||
|
"cardMovePrivateTitle": "Make “{title}” private?",
|
||||||
|
"@cardMovePrivateTitle": {"placeholders": {"title": {"type": "String"}}},
|
||||||
|
"cardMovePrivateBody": "These flashcards are private. “{title}” is readable by the instance, so they can only move there once it is private too.",
|
||||||
|
"@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?",
|
"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.",
|
"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",
|
"libraryOfferPrivateAction": "Make all private",
|
||||||
|
|
|
||||||
|
|
@ -6946,6 +6946,12 @@ abstract class AppLocalizations {
|
||||||
/// **'This file is larger than {megabytes} MB.'**
|
/// **'This file is larger than {megabytes} MB.'**
|
||||||
String fileTooLarge(int megabytes);
|
String fileTooLarge(int megabytes);
|
||||||
|
|
||||||
|
/// No description provided for @fileAfterCreate.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Pictures and sounds can be added once the studyset is made.'**
|
||||||
|
String get fileAfterCreate;
|
||||||
|
|
||||||
/// No description provided for @fileNotKept.
|
/// No description provided for @fileNotKept.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -12219,12 +12225,6 @@ abstract class AppLocalizations {
|
||||||
/// **'Some of it couldn\'t be opened on this device and stayed as it was.'**
|
/// **'Some of it couldn\'t be opened on this device and stayed as it was.'**
|
||||||
String get studysetPrivateSomeLocked;
|
String get studysetPrivateSomeLocked;
|
||||||
|
|
||||||
/// No description provided for @studysetPrivateLabel.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Private'**
|
|
||||||
String get studysetPrivateLabel;
|
|
||||||
|
|
||||||
/// No description provided for @studysetLockedTitle.
|
/// No description provided for @studysetLockedTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -12237,6 +12237,54 @@ abstract class AppLocalizations {
|
||||||
/// **'This studyset is private, and its key doesn\'t open on this device: it was encrypted with keys your account no longer has. It can only be deleted.'**
|
/// **'This studyset is private, and its key doesn\'t open on this device: it was encrypted with keys your account no longer has. It can only be deleted.'**
|
||||||
String get studysetLockedBody;
|
String get studysetLockedBody;
|
||||||
|
|
||||||
|
/// No description provided for @studysetVisibilityPartly.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Private, partly readable'**
|
||||||
|
String get studysetVisibilityPartly;
|
||||||
|
|
||||||
|
/// No description provided for @studysetPrivacyPartlyBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Some of it is still readable by the instance: what a device wrote before it heard, or files not moved yet. It is sealed as soon as a device with your keys can, or now with Finish.'**
|
||||||
|
String get studysetPrivacyPartlyBody;
|
||||||
|
|
||||||
|
/// No description provided for @studysetPrivacyFinish.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Finish'**
|
||||||
|
String get studysetPrivacyFinish;
|
||||||
|
|
||||||
|
/// No description provided for @studysetLockedNoKeysBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This studyset is private, and its key isn\'t on this device. Sign in with your password on this device to open it.'**
|
||||||
|
String get studysetLockedNoKeysBody;
|
||||||
|
|
||||||
|
/// No description provided for @cardMovePrivateTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Make “{title}” private?'**
|
||||||
|
String cardMovePrivateTitle(String title);
|
||||||
|
|
||||||
|
/// No description provided for @cardMovePrivateBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'These flashcards are private. “{title}” is readable by the instance, so they can only move there once it is private too.'**
|
||||||
|
String cardMovePrivateBody(String title);
|
||||||
|
|
||||||
|
/// No description provided for @cardMovePrivateAction.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Make private and move'**
|
||||||
|
String get cardMovePrivateAction;
|
||||||
|
|
||||||
|
/// No description provided for @privacyKeysMissing.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'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.
|
/// No description provided for @libraryOfferPrivateTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -4315,6 +4315,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
return 'Diese Datei ist größer als $megabytes MB.';
|
return 'Diese Datei ist größer als $megabytes MB.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fileAfterCreate =>
|
||||||
|
'Bilder und Töne lassen sich hinzufügen, sobald das Lernset erstellt ist.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get fileNotKept =>
|
String get fileNotKept =>
|
||||||
'Die Datei konnte nicht auf diesem Gerät gespeichert werden.';
|
'Die Datei konnte nicht auf diesem Gerät gespeichert werden.';
|
||||||
|
|
@ -7747,9 +7751,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get studysetPrivateSomeLocked =>
|
String get studysetPrivateSomeLocked =>
|
||||||
'Manches darin ließ sich auf diesem Gerät nicht öffnen und blieb, wie es war.';
|
'Manches darin ließ sich auf diesem Gerät nicht öffnen und blieb, wie es war.';
|
||||||
|
|
||||||
@override
|
|
||||||
String get studysetPrivateLabel => 'Privat';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get studysetLockedTitle => 'Gesperrt';
|
String get studysetLockedTitle => 'Gesperrt';
|
||||||
|
|
||||||
|
|
@ -7757,6 +7758,37 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get studysetLockedBody =>
|
String get studysetLockedBody =>
|
||||||
'Dieses Lernset ist privat, und sein Schlüssel öffnet sich auf diesem Gerät nicht: Es wurde mit Schlüsseln verschlüsselt, die dein Konto nicht mehr hat. Es lässt sich nur noch löschen.';
|
'Dieses Lernset ist privat, und sein Schlüssel öffnet sich auf diesem Gerät nicht: Es wurde mit Schlüsseln verschlüsselt, die dein Konto nicht mehr hat. Es lässt sich nur noch löschen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetVisibilityPartly => 'Privat, teils lesbar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetPrivacyPartlyBody =>
|
||||||
|
'Manches darin kann die Instanz noch lesen: was ein Gerät schrieb, bevor es davon wusste, oder Dateien, die noch nicht umgezogen sind. Es wird versiegelt, sobald ein Gerät mit deinen Schlüsseln es kann, oder jetzt mit Abschließen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetPrivacyFinish => 'Abschließen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetLockedNoKeysBody =>
|
||||||
|
'Dieses Lernset ist privat, und sein Schlüssel ist nicht auf diesem Gerät. Melde dich auf diesem Gerät mit deinem Passwort an, um es zu öffnen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cardMovePrivateTitle(String title) {
|
||||||
|
return '„$title“ privat machen?';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cardMovePrivateBody(String title) {
|
||||||
|
return 'Diese Karteikarten sind privat. „$title“ kann die Instanz lesen, also können sie erst dorthin, wenn es auch privat ist.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cardMovePrivateAction => 'Privat machen und verschieben';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get privacyKeysMissing =>
|
||||||
|
'Melde dich auf diesem Gerät mit deinem Passwort an: Was du hier hinzufügst oder änderst, wäre privat.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryOfferPrivateTitle => 'Deine Lernsets privat machen?';
|
String get libraryOfferPrivateTitle => 'Deine Lernsets privat machen?';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4277,6 +4277,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
return 'This file is larger than $megabytes MB.';
|
return 'This file is larger than $megabytes MB.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fileAfterCreate =>
|
||||||
|
'Pictures and sounds can be added once the studyset is made.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get fileNotKept => 'The file couldn\'t be kept on this device.';
|
String get fileNotKept => 'The file couldn\'t be kept on this device.';
|
||||||
|
|
||||||
|
|
@ -7674,9 +7678,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get studysetPrivateSomeLocked =>
|
String get studysetPrivateSomeLocked =>
|
||||||
'Some of it couldn\'t be opened on this device and stayed as it was.';
|
'Some of it couldn\'t be opened on this device and stayed as it was.';
|
||||||
|
|
||||||
@override
|
|
||||||
String get studysetPrivateLabel => 'Private';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get studysetLockedTitle => 'Locked';
|
String get studysetLockedTitle => 'Locked';
|
||||||
|
|
||||||
|
|
@ -7684,6 +7685,37 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get studysetLockedBody =>
|
String get studysetLockedBody =>
|
||||||
'This studyset is private, and its key doesn\'t open on this device: it was encrypted with keys your account no longer has. It can only be deleted.';
|
'This studyset is private, and its key doesn\'t open on this device: it was encrypted with keys your account no longer has. It can only be deleted.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetVisibilityPartly => 'Private, partly readable';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetPrivacyPartlyBody =>
|
||||||
|
'Some of it is still readable by the instance: what a device wrote before it heard, or files not moved yet. It is sealed as soon as a device with your keys can, or now with Finish.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetPrivacyFinish => 'Finish';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetLockedNoKeysBody =>
|
||||||
|
'This studyset is private, and its key isn\'t on this device. Sign in with your password on this device to open it.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cardMovePrivateTitle(String title) {
|
||||||
|
return 'Make “$title” private?';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cardMovePrivateBody(String title) {
|
||||||
|
return 'These flashcards are private. “$title” is readable by the instance, so they can only move there once it is private too.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cardMovePrivateAction => 'Make private and move';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get privacyKeysMissing =>
|
||||||
|
'Sign in with your password on this device: what you add or change here would be private.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryOfferPrivateTitle => 'Make your studysets private?';
|
String get libraryOfferPrivateTitle => 'Make your studysets private?';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,18 @@ class AssetRepository(
|
||||||
Stream<void> uploadTriggers = const Stream.empty(),
|
Stream<void> uploadTriggers = const Stream.empty(),
|
||||||
}) implements TreeFiles {
|
}) implements TreeFiles {
|
||||||
this {
|
this {
|
||||||
_triggers = uploadTriggers.listen((_) => unawaited(uploadPending()));
|
_triggers = uploadTriggers.listen((_) {
|
||||||
|
// A sync may have brought rows: the index is read anew.
|
||||||
|
_sealedIndex = null;
|
||||||
|
unawaited(uploadPending());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The sealed rows by their key and the hash of what they hold
|
||||||
|
/// (`<keyId>|<sha256>`), read once until rows change, so adding many files
|
||||||
|
/// doesn't open every row for each (the reading of 22 Oct).
|
||||||
|
Map<String, AssetRow>? _sealedIndex;
|
||||||
|
|
||||||
/// The largest file the server takes, of any kind but a PDF.
|
/// The largest file the server takes, of any kind but a PDF.
|
||||||
static const maxBytes = 20 * 1024 * 1024;
|
static const maxBytes = 20 * 1024 * 1024;
|
||||||
|
|
||||||
|
|
@ -161,6 +170,8 @@ class AssetRepository(
|
||||||
payload: encodeAssetPayload(fileName: fileName, plainSha256: plain),
|
payload: encodeAssetPayload(fileName: fileName, plainSha256: plain),
|
||||||
keyId: keyId,
|
keyId: keyId,
|
||||||
);
|
);
|
||||||
|
// Read anew next time, with this row.
|
||||||
|
_sealedIndex = null;
|
||||||
await _files.markPending(hash);
|
await _files.markPending(hash);
|
||||||
return id;
|
return id;
|
||||||
});
|
});
|
||||||
|
|
@ -169,12 +180,12 @@ class AssetRepository(
|
||||||
/// The row of the file whose content before sealing has [plain] as its
|
/// The row of the file whose content before sealing has [plain] as its
|
||||||
/// hash, sealed with [keyId]; null while there is none.
|
/// hash, sealed with [keyId]; null while there is none.
|
||||||
Future<AssetRow?> _sealedRowOf(String plain, String keyId) async {
|
Future<AssetRow?> _sealedRowOf(String plain, String keyId) async {
|
||||||
for (final row in await _rows.all()) {
|
final index = _sealedIndex ??= {
|
||||||
if (row.keyId == keyId && !row.locked && _plainOf(row) == plain) {
|
for (final row in await _rows.all())
|
||||||
return row;
|
if (row.keyId case final rowKey? when !row.locked)
|
||||||
}
|
if (_plainOf(row) case final rowPlain?) '$rowKey|$rowPlain': row,
|
||||||
}
|
};
|
||||||
return null;
|
return index['$keyId|$plain'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The hash of what [row]'s sealed file holds; null for a readable row,
|
/// The hash of what [row]'s sealed file holds; null for a readable row,
|
||||||
|
|
@ -351,8 +362,15 @@ class AssetRepository(
|
||||||
// Gone from this device: nothing to upload, ever.
|
// Gone from this device: nothing to upload, ever.
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
final row = await _rows.bySha256(hash);
|
||||||
|
if (row == null) {
|
||||||
|
// No live row names it any more, as one a private studyset's rewrite
|
||||||
|
// replaced: never uploaded, least of all readable (the reading of 22
|
||||||
|
// Oct).
|
||||||
|
return true;
|
||||||
|
}
|
||||||
// A sealed file says so, as only a readable one is looked into.
|
// A sealed file says so, as only a readable one is looked into.
|
||||||
final encrypted = (await _rows.bySha256(hash))?.keyId != null;
|
final encrypted = row.keyId != null;
|
||||||
final description = await _server.startUpload(
|
final description = await _server.startUpload(
|
||||||
hash,
|
hash,
|
||||||
bytes.length,
|
bytes.length,
|
||||||
|
|
@ -383,7 +401,16 @@ class AssetRepository(
|
||||||
/// server once nothing refers to it, and stops taking the person's room.
|
/// server once nothing refers to it, and stops taking the person's room.
|
||||||
/// Only for files no content refers to any more.
|
/// Only for files no content refers to any more.
|
||||||
@override
|
@override
|
||||||
Future<void> delete(String id) => _rows.delete(id);
|
Future<void> delete(String id) async {
|
||||||
|
final row = await _rows.byId(id);
|
||||||
|
await _rows.delete(id);
|
||||||
|
_sealedIndex = null;
|
||||||
|
// Its upload, if still waiting, is no longer owed (the reading of 22
|
||||||
|
// Oct), unless another row holds the same file.
|
||||||
|
if (row != null && await _rows.bySha256(row.sha256) == null) {
|
||||||
|
await _files.clearPending(row.sha256);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Stops uploading; an upload under way finishes first.
|
/// Stops uploading; an upload under way finishes first.
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,13 @@ import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
/// The studyset files added beneath belong to (ADR 0019): a private one's
|
/// The studyset files added beneath belong to (ADR 0019): a private one's
|
||||||
/// are sealed with its tree's key. Every screen of a studyset sets it, so an
|
/// are sealed with its tree's key. Every screen of a studyset sets it, so an
|
||||||
/// editor adding a picture or a sound knows where it goes.
|
/// editor adding a picture or a sound knows where it goes; null where what
|
||||||
|
/// is written beneath is readable by design (Explore's description). Where
|
||||||
|
/// none is above, an editor adds no file for an account with keys (the
|
||||||
|
/// reading of 22 Oct), as it can't know whether it would be private.
|
||||||
class const StudysetFiles({
|
class const StudysetFiles({
|
||||||
/// The studyset's id.
|
/// The studyset's id; null for readable on purpose.
|
||||||
required final String studysetId,
|
required final String? studysetId,
|
||||||
required super.child,
|
required super.child,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends InheritedWidget {
|
}) extends InheritedWidget {
|
||||||
|
|
@ -14,6 +17,14 @@ class const StudysetFiles({
|
||||||
static String? of(BuildContext context) =>
|
static String? of(BuildContext context) =>
|
||||||
context.getInheritedWidgetOfExactType<StudysetFiles>()?.studysetId;
|
context.getInheritedWidgetOfExactType<StudysetFiles>()?.studysetId;
|
||||||
|
|
||||||
|
/// Where files added at [context] go: the studyset's id (null for
|
||||||
|
/// readable on purpose), or null where no [StudysetFiles] is above.
|
||||||
|
static ({String? studysetId})? scopeOf(BuildContext context) =>
|
||||||
|
switch (context.getInheritedWidgetOfExactType<StudysetFiles>()) {
|
||||||
|
final files? => (studysetId: files.studysetId),
|
||||||
|
null => null,
|
||||||
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool updateShouldNotify(StudysetFiles oldWidget) =>
|
bool updateShouldNotify(StudysetFiles oldWidget) =>
|
||||||
studysetId != oldWidget.studysetId;
|
studysetId != oldWidget.studysetId;
|
||||||
|
|
|
||||||
|
|
@ -88,8 +88,18 @@ Future<void> _insertFile(
|
||||||
) async {
|
) async {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||||
final assets = PersonDependencies.of(context).assets;
|
final person = PersonDependencies.of(context);
|
||||||
final studysetId = StudysetFiles.of(context);
|
final assets = person.assets;
|
||||||
|
// Fails closed (the reading of 22 Oct): where it isn't known which
|
||||||
|
// studyset a file belongs to, an account with keys adds none, as it
|
||||||
|
// might be private.
|
||||||
|
final scope = StudysetFiles.scopeOf(context);
|
||||||
|
if (scope == null &&
|
||||||
|
(person.studysets.canMakePrivate || person.studysets.keysMissing)) {
|
||||||
|
messenger?.showSnackBar(SnackBar(content: Text(l10n.fileAfterCreate)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final studysetId = scope?.studysetId;
|
||||||
final image = kind == _FileKind.image;
|
final image = kind == _FileKind.image;
|
||||||
final file = await Dependencies.of(context).files.pick(
|
final file = await Dependencies.of(context).files.pick(
|
||||||
label: image ? l10n.fileImages : l10n.fileSounds,
|
label: image ? l10n.fileImages : l10n.fileSounds,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/asset/widget/studyset_files.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
|
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
|
||||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||||
|
|
@ -9,18 +10,37 @@ import 'package:material_ui/material_ui.dart';
|
||||||
/// Edits rich text in the whole window, over what opened it, with the format
|
/// Edits rich text in the whole window, over what opened it, with the format
|
||||||
/// bar while writing: a studyset's description, a step's feedback. Completes
|
/// bar while writing: a studyset's description, a step's feedback. Completes
|
||||||
/// with the new text once saved, or with null when left without saving.
|
/// with the new text once saved, or with null when left without saving.
|
||||||
|
///
|
||||||
|
/// Files added in it go where they would where it was opened
|
||||||
|
/// ([StudysetFiles]), or to [studysetId]'s tree: the route is above the
|
||||||
|
/// screen that opened it, so it carries that along (the reading of 22 Oct).
|
||||||
Future<RichContent?> editRichText(
|
Future<RichContent?> editRichText(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
RichContent initial, {
|
RichContent initial, {
|
||||||
|
|
||||||
/// The app bar's title, saying what is being written.
|
/// The app bar's title, saying what is being written.
|
||||||
required String title,
|
required String title,
|
||||||
}) => Navigator.of(context).push<RichContent>(
|
|
||||||
MaterialPageRoute(
|
/// The studyset what is written belongs to, where it isn't the one it is
|
||||||
fullscreenDialog: true,
|
/// opened from.
|
||||||
builder: (context) => _RichTextEditor(initial, title: title),
|
String? studysetId,
|
||||||
),
|
}) {
|
||||||
);
|
final scope = studysetId != null
|
||||||
|
? (studysetId: studysetId)
|
||||||
|
: StudysetFiles.scopeOf(context);
|
||||||
|
return Navigator.of(context).push<RichContent>(
|
||||||
|
MaterialPageRoute(
|
||||||
|
fullscreenDialog: true,
|
||||||
|
builder: (context) => switch (scope) {
|
||||||
|
(:final studysetId) => StudysetFiles(
|
||||||
|
studysetId: studysetId,
|
||||||
|
child: _RichTextEditor(initial, title: title),
|
||||||
|
),
|
||||||
|
null => _RichTextEditor(initial, title: title),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
class const _RichTextEditor(
|
class const _RichTextEditor(
|
||||||
final RichContent initial, {
|
final RichContent initial, {
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,33 @@ class _BrowserState() extends State<_Browser> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Asks whether to make [title], where private cards are to go, private
|
||||||
|
/// first.
|
||||||
|
Future<bool> _makePrivateFirst(String title) async {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
return await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
icon: const Icon(LucideIcons.lock),
|
||||||
|
title: Text(l10n.cardMovePrivateTitle(title)),
|
||||||
|
content: Text(l10n.cardMovePrivateBody(title)),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: Text(
|
||||||
|
MaterialLocalizations.of(context).cancelButtonLabel,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
child: Text(l10n.cardMovePrivateAction),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
) ??
|
||||||
|
false;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _move(CardBrowserState state) async {
|
Future<void> _move(CardBrowserState state) async {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
|
@ -195,6 +222,20 @@ class _BrowserState() extends State<_Browser> {
|
||||||
.title ??
|
.title ??
|
||||||
'',
|
'',
|
||||||
};
|
};
|
||||||
|
// Private cards never move into a readable studyset (the reading of 22
|
||||||
|
// Oct): the person is asked to make it private first.
|
||||||
|
final into = (await person.studysets.all())
|
||||||
|
.where((each) => each.id == target)
|
||||||
|
.firstOrNull;
|
||||||
|
if (widget.studyset.isPrivate && into != null && !into.isPrivate) {
|
||||||
|
if (!mounted || !await _makePrivateFirst(title)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await person.studysets.setPrivate(target, private: true);
|
||||||
|
}
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
_bloc.add(CardBrowserEventMoved(target));
|
_bloc.add(CardBrowserEventMoved(target));
|
||||||
messenger
|
messenger
|
||||||
..hideCurrentSnackBar()
|
..hideCurrentSnackBar()
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,11 @@ class AddDestinationCubit(
|
||||||
late final StreamSubscription<void> _subscription;
|
late final StreamSubscription<void> _subscription;
|
||||||
|
|
||||||
Future<void> _read() async {
|
Future<void> _read() async {
|
||||||
final options = await _adder.destinationsOf(_studysetId);
|
// A locked studyset takes nothing (the reading of 22 Oct).
|
||||||
|
final options = [
|
||||||
|
for (final studyset in await _adder.destinationsOf(_studysetId))
|
||||||
|
if (!studyset.isLocked) studyset,
|
||||||
|
];
|
||||||
final chosen = await _adder.destinationOf(_studysetId);
|
final chosen = await _adder.destinationOf(_studysetId);
|
||||||
final byId = {for (final studyset in options) studyset.id: studyset};
|
final byId = {for (final studyset in options) studyset.id: studyset};
|
||||||
int depthOf(Studyset studyset) => switch (byId[studyset.parentId]) {
|
int depthOf(Studyset studyset) => switch (byId[studyset.parentId]) {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart'
|
||||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_sealing.dart';
|
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_sealing.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
|
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/profile/public_person.dart';
|
import 'package:halkyon_learn_flutter/src/core/profile/public_person.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||||
|
|
@ -116,6 +117,17 @@ class FeedbackRepository(
|
||||||
final files = <FeedbackFile>[];
|
final files = <FeedbackFile>[];
|
||||||
for (final assetId in assetIdsIn(proposed)) {
|
for (final assetId in assetIdsIn(proposed)) {
|
||||||
if (await _assets.fileOf(assetId) case final file?) {
|
if (await _assets.fileOf(assetId) case final file?) {
|
||||||
|
// A sealed file (a private copy's) is uploaded readable too, by the
|
||||||
|
// hash the author asks for it with: a suggestion is for its author
|
||||||
|
// to read (the reading of 22 Oct).
|
||||||
|
if (await _assets.keyIdOf(assetId) != null) {
|
||||||
|
await _assets.add(
|
||||||
|
file.bytes,
|
||||||
|
fileName: file.fileName,
|
||||||
|
mimeType: file.mimeType,
|
||||||
|
studysetId: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
files.add(
|
files.add(
|
||||||
FeedbackFile(
|
FeedbackFile(
|
||||||
assetId: assetId,
|
assetId: assetId,
|
||||||
|
|
@ -169,7 +181,9 @@ class FeedbackRepository(
|
||||||
FeedbackTargetKind.studyset => throw SuggestionNotTaken(),
|
FeedbackTargetKind.studyset => throw SuggestionNotTaken(),
|
||||||
};
|
};
|
||||||
final item = thread.target.item;
|
final item = thread.target.item;
|
||||||
var text = jsonEncode(proposed);
|
// Whole `asset:` values alone are swapped, never text: the writer
|
||||||
|
// chooses the ids (the reading of 22 Oct).
|
||||||
|
final swaps = <String, String>{};
|
||||||
for (final file in thread.files) {
|
for (final file in thread.files) {
|
||||||
final own =
|
final own =
|
||||||
await _assets.idInItem(file.sha256, kind: kind, itemId: item) ??
|
await _assets.idInItem(file.sha256, kind: kind, itemId: item) ??
|
||||||
|
|
@ -180,9 +194,12 @@ class FeedbackRepository(
|
||||||
kind: kind,
|
kind: kind,
|
||||||
itemId: item,
|
itemId: item,
|
||||||
);
|
);
|
||||||
text = text.replaceAll(file.assetId, own);
|
swaps[file.assetId] = own;
|
||||||
}
|
}
|
||||||
final payload = ByteData.sublistView(utf8.encode(text));
|
final payload = Privacy.withAssets(
|
||||||
|
ByteData.sublistView(utf8.encode(jsonEncode(proposed))),
|
||||||
|
swaps,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
switch (thread.target.kind) {
|
switch (thread.target.kind) {
|
||||||
case FeedbackTargetKind.card:
|
case FeedbackTargetKind.card:
|
||||||
|
|
|
||||||
|
|
@ -50,15 +50,16 @@ class const ShareButton({
|
||||||
!state.publication!.withdrawn;
|
!state.publication!.withdrawn;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a studyset's marker shows (decided 21 Oct 2026): a globe once it
|
/// What a studyset's marker shows (decided 21 Oct 2026): a lock while it
|
||||||
/// is published, a lock while it is private (end-to-end encrypted, ADR
|
/// is private (end-to-end encrypted, ADR 0019), published before or not,
|
||||||
/// 0019), an open lock while the instance can read it and it isn't
|
/// and a shield with a warning while something in it is still readable or
|
||||||
/// published.
|
/// unsealed (the reading of 22 Oct); else a globe once it is published, an
|
||||||
|
/// open lock while the instance can read it and it isn't.
|
||||||
IconData visibilityIcon(Studyset studyset, {required bool published}) =>
|
IconData visibilityIcon(Studyset studyset, {required bool published}) =>
|
||||||
published
|
studyset.isPrivate
|
||||||
|
? (studyset.isPartlyReadable ? LucideIcons.shieldAlert : LucideIcons.lock)
|
||||||
|
: published
|
||||||
? LucideIcons.globe
|
? LucideIcons.globe
|
||||||
: studyset.isPrivate
|
|
||||||
? LucideIcons.lock
|
|
||||||
: LucideIcons.lockOpen;
|
: LucideIcons.lockOpen;
|
||||||
|
|
||||||
/// What a studyset's marker says of it, as [visibilityIcon] shows it.
|
/// What a studyset's marker says of it, as [visibilityIcon] shows it.
|
||||||
|
|
@ -66,10 +67,12 @@ String visibilityLabel(
|
||||||
AppLocalizations l10n,
|
AppLocalizations l10n,
|
||||||
Studyset studyset, {
|
Studyset studyset, {
|
||||||
required bool published,
|
required bool published,
|
||||||
}) => published
|
}) => studyset.isPrivate
|
||||||
|
? (studyset.isPartlyReadable
|
||||||
|
? l10n.studysetVisibilityPartly
|
||||||
|
: l10n.studysetVisibilityPrivate)
|
||||||
|
: published
|
||||||
? l10n.studysetVisibilityPublic
|
? l10n.studysetVisibilityPublic
|
||||||
: studyset.isPrivate
|
|
||||||
? l10n.studysetVisibilityPrivate
|
|
||||||
: l10n.studysetVisibilityReadable;
|
: l10n.studysetVisibilityReadable;
|
||||||
|
|
||||||
/// Opens what explains [studyset]'s marker and changes it: the Publish
|
/// Opens what explains [studyset]'s marker and changes it: the Publish
|
||||||
|
|
@ -82,7 +85,7 @@ Future<void> openVisibility(
|
||||||
required bool published,
|
required bool published,
|
||||||
required VoidCallback openPublish,
|
required VoidCallback openPublish,
|
||||||
}) async {
|
}) async {
|
||||||
if (published) {
|
if (published && !studyset.isPrivate) {
|
||||||
openPublish();
|
openPublish();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,11 +30,13 @@ import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||||
// are none, and hiding the tags made for Jellyfin on cards being studied
|
// are none, and hiding the tags made for Jellyfin on cards being studied
|
||||||
// (`hideJellyfinTags`), left out while off. Schema 10 added where words
|
// (`hideJellyfinTags`), left out while off. Schema 10 added where words
|
||||||
// added from a studyset go (`addDestinations`: by studyset id, a topic's
|
// added from a studyset go (`addDestinations`: by studyset id, a topic's
|
||||||
// id), left out while none is chosen. Older schemas read with what they
|
// id), left out while none is chosen. Schema 11 added that the library's
|
||||||
// lack as it comes.
|
// offer to make every studyset private was answered
|
||||||
|
// (`privateOfferAnswered`, ADR 0019), left out while it wasn't. Older
|
||||||
|
// schemas read with what they lack as it comes.
|
||||||
|
|
||||||
/// The schema this app writes.
|
/// The schema this app writes.
|
||||||
const settingsSchemaVersion = 10;
|
const settingsSchemaVersion = 11;
|
||||||
|
|
||||||
/// Encodes [settings] into their payload.
|
/// Encodes [settings] into their payload.
|
||||||
ByteData encodeSettingsPayload(PersonSettings settings) {
|
ByteData encodeSettingsPayload(PersonSettings settings) {
|
||||||
|
|
@ -86,6 +88,7 @@ ByteData encodeSettingsPayload(PersonSettings settings) {
|
||||||
'selected': pomodoro.selectedId,
|
'selected': pomodoro.selectedId,
|
||||||
},
|
},
|
||||||
if (settings.countLearning) 'countLearning': true,
|
if (settings.countLearning) 'countLearning': true,
|
||||||
|
if (settings.privateOfferAnswered) 'privateOfferAnswered': true,
|
||||||
if (settings.hideJellyfinTags) 'hideJellyfinTags': true,
|
if (settings.hideJellyfinTags) 'hideJellyfinTags': true,
|
||||||
if (settings.jellyfinTies.isNotEmpty)
|
if (settings.jellyfinTies.isNotEmpty)
|
||||||
'jellyfinTies': {
|
'jellyfinTies': {
|
||||||
|
|
@ -130,6 +133,7 @@ PersonSettings decodeSettingsPayload(ByteData payload) {
|
||||||
home: _home(json['home']),
|
home: _home(json['home']),
|
||||||
pomodoro: _pomodoro(json['pomodoro']),
|
pomodoro: _pomodoro(json['pomodoro']),
|
||||||
countLearning: json['countLearning'] == true,
|
countLearning: json['countLearning'] == true,
|
||||||
|
privateOfferAnswered: json['privateOfferAnswered'] == true,
|
||||||
jellyfinTies: _jellyfinTies(json['jellyfinTies']),
|
jellyfinTies: _jellyfinTies(json['jellyfinTies']),
|
||||||
hideJellyfinTags: json['hideJellyfinTags'] == true,
|
hideJellyfinTags: json['hideJellyfinTags'] == true,
|
||||||
addDestinations: {
|
addDestinations: {
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,11 @@ final class const PersonSettings({
|
||||||
/// ADR 0005); nobody is counted unless they choose to be.
|
/// ADR 0005); nobody is counted unless they choose to be.
|
||||||
final bool countLearning = false,
|
final bool countLearning = false,
|
||||||
|
|
||||||
|
/// Whether the person answered the library's offer to make every
|
||||||
|
/// studyset private (ADR 0019, D4), on any of their devices: asked once,
|
||||||
|
/// whatever the answer.
|
||||||
|
final bool privateOfferAnswered = false,
|
||||||
|
|
||||||
/// The Jellyfin shows, films and videos each studyset is tied to, by the
|
/// The Jellyfin shows, films and videos each studyset is tied to, by the
|
||||||
/// studyset's id, in the order tied: ids only, of any of the person's
|
/// studyset's id, in the order tied: ids only, of any of the person's
|
||||||
/// servers.
|
/// servers.
|
||||||
|
|
@ -120,6 +125,7 @@ final class const PersonSettings({
|
||||||
home: home,
|
home: home,
|
||||||
pomodoro: pomodoro,
|
pomodoro: pomodoro,
|
||||||
countLearning: countLearning,
|
countLearning: countLearning,
|
||||||
|
privateOfferAnswered: privateOfferAnswered,
|
||||||
jellyfinTies: jellyfinTies,
|
jellyfinTies: jellyfinTies,
|
||||||
hideJellyfinTags: hideJellyfinTags,
|
hideJellyfinTags: hideJellyfinTags,
|
||||||
addDestinations: addDestinations,
|
addDestinations: addDestinations,
|
||||||
|
|
@ -138,6 +144,7 @@ final class const PersonSettings({
|
||||||
HomeLayout? home,
|
HomeLayout? home,
|
||||||
PomodoroSettings? pomodoro,
|
PomodoroSettings? pomodoro,
|
||||||
bool? countLearning,
|
bool? countLearning,
|
||||||
|
bool? privateOfferAnswered,
|
||||||
Map<String, List<JellyfinTie>>? jellyfinTies,
|
Map<String, List<JellyfinTie>>? jellyfinTies,
|
||||||
bool? hideJellyfinTags,
|
bool? hideJellyfinTags,
|
||||||
Map<String, String>? addDestinations,
|
Map<String, String>? addDestinations,
|
||||||
|
|
@ -149,6 +156,7 @@ final class const PersonSettings({
|
||||||
home: home ?? this.home,
|
home: home ?? this.home,
|
||||||
pomodoro: pomodoro ?? this.pomodoro,
|
pomodoro: pomodoro ?? this.pomodoro,
|
||||||
countLearning: countLearning ?? this.countLearning,
|
countLearning: countLearning ?? this.countLearning,
|
||||||
|
privateOfferAnswered: privateOfferAnswered ?? this.privateOfferAnswered,
|
||||||
jellyfinTies: jellyfinTies ?? this.jellyfinTies,
|
jellyfinTies: jellyfinTies ?? this.jellyfinTies,
|
||||||
hideJellyfinTags: hideJellyfinTags ?? this.hideJellyfinTags,
|
hideJellyfinTags: hideJellyfinTags ?? this.hideJellyfinTags,
|
||||||
addDestinations: addDestinations ?? this.addDestinations,
|
addDestinations: addDestinations ?? this.addDestinations,
|
||||||
|
|
@ -167,6 +175,7 @@ final class const PersonSettings({
|
||||||
pomodoro: pomodoro == const PomodoroSettings() ? other.pomodoro : pomodoro,
|
pomodoro: pomodoro == const PomodoroSettings() ? other.pomodoro : pomodoro,
|
||||||
// Left as it comes (off) counts as none.
|
// Left as it comes (off) counts as none.
|
||||||
countLearning: countLearning || other.countLearning,
|
countLearning: countLearning || other.countLearning,
|
||||||
|
privateOfferAnswered: privateOfferAnswered || other.privateOfferAnswered,
|
||||||
jellyfinTies: {...other.jellyfinTies, ...jellyfinTies},
|
jellyfinTies: {...other.jellyfinTies, ...jellyfinTies},
|
||||||
hideJellyfinTags: hideJellyfinTags || other.hideJellyfinTags,
|
hideJellyfinTags: hideJellyfinTags || other.hideJellyfinTags,
|
||||||
addDestinations: {...other.addDestinations, ...addDestinations},
|
addDestinations: {...other.addDestinations, ...addDestinations},
|
||||||
|
|
@ -181,6 +190,7 @@ final class const PersonSettings({
|
||||||
home,
|
home,
|
||||||
pomodoro,
|
pomodoro,
|
||||||
countLearning,
|
countLearning,
|
||||||
|
privateOfferAnswered,
|
||||||
jellyfinTies,
|
jellyfinTies,
|
||||||
hideJellyfinTags,
|
hideJellyfinTags,
|
||||||
addDestinations,
|
addDestinations,
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,11 @@ abstract interface class StudysetGateway() {
|
||||||
/// account's keys (ADR 0019, D8).
|
/// account's keys (ADR 0019, D8).
|
||||||
bool get canMakePrivate;
|
bool get canMakePrivate;
|
||||||
|
|
||||||
|
/// Whether the account has keys this device doesn't hold: it writes
|
||||||
|
/// nothing here that would be private, new studysets among them (the
|
||||||
|
/// reading of 22 Oct).
|
||||||
|
bool get keysMissing;
|
||||||
|
|
||||||
/// Replaces a studyset's payload, as of [updatedAt].
|
/// Replaces a studyset's payload, as of [updatedAt].
|
||||||
Future<void> updatePayload(String id, ByteData payload, DateTime updatedAt);
|
Future<void> updatePayload(String id, ByteData payload, DateTime updatedAt);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,10 @@ class StudysetRepository(final StudysetGateway _gateway) {
|
||||||
/// account's keys (ADR 0019, D8).
|
/// account's keys (ADR 0019, D8).
|
||||||
bool get canMakePrivate => _gateway.canMakePrivate;
|
bool get canMakePrivate => _gateway.canMakePrivate;
|
||||||
|
|
||||||
|
/// Whether the account has keys this device doesn't hold, so it makes no
|
||||||
|
/// studyset here (the reading of 22 Oct).
|
||||||
|
bool get keysMissing => _gateway.keysMissing;
|
||||||
|
|
||||||
/// Makes the top-level studyset [id] private, with everything inside it,
|
/// Makes the top-level studyset [id] private, with everything inside it,
|
||||||
/// or readable by the instance again (ADR 0019): each of its rows is
|
/// or readable by the instance again (ADR 0019): each of its rows is
|
||||||
/// written anew on this device and synced. Returns how many rows couldn't
|
/// written anew on this device and synced. Returns how many rows couldn't
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,10 @@ class SyncStudysetGateway(
|
||||||
@override
|
@override
|
||||||
bool get canMakePrivate => _privacy.keys.canEncrypt;
|
bool get canMakePrivate => _privacy.keys.canEncrypt;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get keysMissing =>
|
||||||
|
!_privacy.keys.canEncrypt && _privacy.keys.accountHasKeys;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> updatePosition(
|
Future<void> updatePosition(
|
||||||
String id,
|
String id,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dar
|
||||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/start_studying.dart';
|
import 'package:halkyon_learn_flutter/src/feature/study/widget/start_studying.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.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/widget/delete_studyset.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';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/deleted_studyset_card.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_actions.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_actions.dart';
|
||||||
|
|
@ -160,9 +161,18 @@ class _LibraryState() extends State<_Library> {
|
||||||
Future<void> _create(BuildContext context) async {
|
Future<void> _create(BuildContext context) async {
|
||||||
final bloc = context.read<StudysetListBloc>();
|
final bloc = context.read<StudysetListBloc>();
|
||||||
final content = await showStudysetSheet(context);
|
final content = await showStudysetSheet(context);
|
||||||
if (content != null) {
|
if (content == null || !context.mounted) {
|
||||||
bloc.add(StudysetListEventCreateRequested(content));
|
return;
|
||||||
}
|
}
|
||||||
|
if (PersonDependencies.of(context).studysets.keysMissing) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(AppLocalizations.of(context).privacyKeysMissing),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bloc.add(StudysetListEventCreateRequested(content));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -671,9 +681,12 @@ class _PrivateOfferState() extends State<_PrivateOffer> {
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
unawaited(() async {
|
unawaited(() async {
|
||||||
final answered = await PersonDependencies.of(
|
final person = PersonDependencies.of(context);
|
||||||
context,
|
// Answered on this device, or on another of the person's (the
|
||||||
).privateOffer.answered();
|
// reading of 22 Oct: the answer follows them).
|
||||||
|
final answered =
|
||||||
|
await person.privateOffer.answered() ||
|
||||||
|
(await person.settings.read()).privateOfferAnswered;
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _answered = answered);
|
setState(() => _answered = answered);
|
||||||
}
|
}
|
||||||
|
|
@ -690,14 +703,32 @@ class _PrivateOfferState() extends State<_PrivateOffer> {
|
||||||
|
|
||||||
Future<void> _answer({required bool makePrivate}) async {
|
Future<void> _answer({required bool makePrivate}) async {
|
||||||
final person = PersonDependencies.of(context);
|
final person = PersonDependencies.of(context);
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
setState(() => _busy = true);
|
setState(() => _busy = true);
|
||||||
try {
|
try {
|
||||||
if (makePrivate) {
|
if (makePrivate) {
|
||||||
for (final studyset in _readable) {
|
// Every top-level studyset, those waiting in Recently deleted and
|
||||||
await person.studysets.setPrivate(studyset.id, private: true);
|
// 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.privateOffer.answer();
|
||||||
|
await person.settings.update(
|
||||||
|
(settings) => settings.copyWith(privateOfferAnswered: true),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,21 @@ List<StudysetOptionEntry> studysetOptions(
|
||||||
bool destroys = false,
|
bool destroys = false,
|
||||||
}) => (option: option, icon: icon, label: label, destroys: destroys);
|
}) => (option: option, icon: icon, label: label, destroys: destroys);
|
||||||
if (studyset.isLocked) {
|
if (studyset.isLocked) {
|
||||||
|
// Where its key isn't on this device, it isn't lost (the reading of 22
|
||||||
|
// Oct): it can still be archived, as that writes no content.
|
||||||
return [
|
return [
|
||||||
|
if (!studyset.keysOnDevice && studyset.isArchived)
|
||||||
|
entry(
|
||||||
|
StudysetOption.unarchive,
|
||||||
|
LucideIcons.archiveRestore,
|
||||||
|
l10n.studysetMenuUnarchive,
|
||||||
|
)
|
||||||
|
else if (!studyset.keysOnDevice)
|
||||||
|
entry(
|
||||||
|
StudysetOption.archive,
|
||||||
|
LucideIcons.archive,
|
||||||
|
l10n.studysetMenuArchive,
|
||||||
|
),
|
||||||
entry(
|
entry(
|
||||||
StudysetOption.delete,
|
StudysetOption.delete,
|
||||||
LucideIcons.trash2,
|
LucideIcons.trash2,
|
||||||
|
|
@ -282,6 +296,7 @@ Future<void> editStudyset(
|
||||||
inheritedLanguages: parentId == null
|
inheritedLanguages: parentId == null
|
||||||
? (front: null, back: null)
|
? (front: null, back: null)
|
||||||
: languagesOf(parentId, all),
|
: languagesOf(parentId, all),
|
||||||
|
studysetId: studyset.id,
|
||||||
);
|
);
|
||||||
if (content != null && content != studyset.content) {
|
if (content != null && content != studyset.content) {
|
||||||
bloc.add(StudysetListEventContentChanged(studyset.id, content));
|
bloc.add(StudysetListEventContentChanged(studyset.id, content));
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue