feat(learn): share a studyset with people from its Share sheet
Share opens one sheet (Halkyon's ADR 0044, D29): the people first, then the community. A studyset of the person's own offers Share with people, which says what it does, moves it into a space of its own with its progress told and Abort, and opens it there (StudysetSharingCubit). A shared one lists its members (SpaceMembersCubit): the Owner invites by handle as Editor or Viewer, changes roles, hands on and removes, and deletes it for everyone; anyone leaves, the Owner naming who takes it on; each refusal is said. A member's row opens their safety code (twelve groups of five digits) to mark compared, and changed keys are flagged, trusted only after comparing (D32). The community row leads to the privacy dialog or publishing as Share did, and is off for a shared studyset (D27). A studyset knows its space and whether the person only views it (Studyset.sharedIn, viewOnly, from the sync engine's grants). Its mark is Lucide's users icon, "Shared · private" (D31). A Viewer's studyset screen, its options, its topics and the move sheet offer nothing that writes, and say they can view and study it (D30); the engine refuses their writes on the device in any case. A shared top isn't moved, archived or deleted from its options, and nothing moves in or out of its space. Whats-New: Share a studyset with people: invite them by their handle as editors or viewers, and compare safety codes to know nobody stands between you. Whats-New-De: Teile ein Lernset mit Personen: Lade sie über ihr Handle als Bearbeiter:innen oder Betrachter:innen ein und vergleicht Sicherheitscodes, damit niemand zwischen euch steht. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
02bb24428c
commit
9b344141ef
40 changed files with 2777 additions and 118 deletions
|
|
@ -2211,5 +2211,64 @@
|
|||
"publishMaterialAllBut": "Alles außer diesem",
|
||||
"publishMaterialOnlyThis": "Nur dieses",
|
||||
"publishMaterialOptions": "Damit auswählen",
|
||||
"publishMaterialCount": "{count, plural, =0{Keine Notizen oder Dateien gehen mit} =1{1 Notiz oder Datei geht mit} other{{count} Notizen und Dateien gehen mit}}"
|
||||
"publishMaterialCount": "{count, plural, =0{Keine Notizen oder Dateien gehen mit} =1{1 Notiz oder Datei geht mit} other{{count} Notizen und Dateien gehen mit}}",
|
||||
"studysetVisibilityShared": "Geteilt · privat",
|
||||
"shareTitle": "{title} teilen",
|
||||
"sharePeople": "Personen",
|
||||
"sharePartOf": "Teil von {title}, das mit diesen Personen geteilt ist:",
|
||||
"sharePeopleIntro": "Lernt und schreibt es gemeinsam. Die anderen sehen es wie du, versiegelt nur für die Personen, die du einlädst.",
|
||||
"shareWithPeople": "Mit Personen teilen",
|
||||
"shareCommunity": "Community",
|
||||
"shareCommunityNotShared": "Für Lernsets, die mit Personen geteilt sind, noch nicht.",
|
||||
"shareAborted": "Teilen abgebrochen. Das Lernset ist wie vorher.",
|
||||
"shareWithPeopleTitle": "{title} mit Personen teilen",
|
||||
"shareWithPeopleConfirm": "Teilen",
|
||||
"shareWithPeopleBody": "Es zieht mit allem darin, auch deinem Fortschritt, in einen eigenen Bereich, der dir gehört. Dann lädst du Personen über ihr Handle ein. Themen bleiben darin, und solange es geteilt ist, lässt es sich nicht veröffentlichen.",
|
||||
"shareRefusedPublished": "Etwas darin ist veröffentlicht oder war es. Lernsets mit einer Veröffentlichung lassen sich noch nicht teilen.",
|
||||
"shareRefusedKeys": "Dieses Gerät hat die Schlüssel deines Kontos nicht. Melde dich zuerst mit deinem Passwort an.",
|
||||
"shareRefusedUnreached": "Deine Instanz war nicht erreichbar. Versuch es noch einmal, sobald du online bist.",
|
||||
"shareFailed": "Teilen ist fehlgeschlagen. Das Lernset ist wie vorher.",
|
||||
"workStageSpace": "Den Bereich anlegen",
|
||||
"workStageCleaning": "Die alte Kopie entfernen",
|
||||
"shareMembersUnreachable": "Deine Instanz war nicht erreichbar.",
|
||||
"shareInvite": "Einladen",
|
||||
"shareInviteTitle": "Jemanden einladen",
|
||||
"shareInviteHandle": "Handle der Person",
|
||||
"shareRoleOwner": "Besitzer:in",
|
||||
"shareRoleEditor": "Bearbeiter:in",
|
||||
"shareRoleViewer": "Betrachter:in",
|
||||
"shareRoleEditorHint": "Lernt es und ändert es mit dir.",
|
||||
"shareRoleViewerHint": "Lernt es, kann es aber nicht ändern.",
|
||||
"shareYou": "du",
|
||||
"shareInvited": "eingeladen",
|
||||
"shareKeysChanged": "Schlüssel geändert",
|
||||
"shareCompared": "Sicherheitscode verglichen",
|
||||
"shareMemberOptions": "Optionen für das Mitglied",
|
||||
"shareMakeEditor": "Zur Bearbeiter:in machen",
|
||||
"shareMakeViewer": "Zur Betrachter:in machen",
|
||||
"shareMakeOwner": "Zur Besitzer:in machen",
|
||||
"shareMakeOwnerTitle": "{name} zur Besitzer:in machen?",
|
||||
"shareMakeOwnerBody": "Ab dann entscheidet diese Person, mit wem es geteilt ist. Du bleibst Bearbeiter:in.",
|
||||
"shareRemove": "Entfernen",
|
||||
"shareRemoveTitle": "{name} entfernen?",
|
||||
"shareRemoveBody": "Diese Person sieht es und alles, was darin von nun an geschrieben wird, nicht mehr.",
|
||||
"shareLeave": "Verlassen",
|
||||
"shareLeaveTitle": "{title} verlassen?",
|
||||
"shareLeaveBody": "Es verschwindet mit deinem Fortschritt darin aus deiner Bibliothek. Damit du zurückkommst, muss dich jemand erneut einladen.",
|
||||
"shareLeaveNextOwner": "Wem gehört es nach dir?",
|
||||
"shareDeleteForEveryone": "Für alle löschen",
|
||||
"shareDeleteTitle": "{title} für alle löschen?",
|
||||
"shareDeleteBody": "Es verschwindet endgültig, mit allem darin und dem Fortschritt aller, aus der Bibliothek jedes Mitglieds. Das lässt sich nicht rückgängig machen.",
|
||||
"shareRefusedNoHandle": "Niemand auf deiner Instanz hat dieses Handle.",
|
||||
"shareRefusedAlreadyMember": "Diese Person ist schon Mitglied oder eingeladen.",
|
||||
"shareRefusedLastMember": "Du bist das einzige Mitglied: lösch es stattdessen.",
|
||||
"shareRefusedUnavailable": "Deine Instanz teilt keine Lernsets.",
|
||||
"shareRefusedNotAllowed": "Das kann nur die Besitzer:in.",
|
||||
"safetyCodeTitle": "Sicherheitscode mit {name}",
|
||||
"safetyCodeHowTo": "Vergleich ihn mit dem Code, den {name} für dich sieht, persönlich oder bei einem Anruf. Stimmen sie überein, steht niemand zwischen euch, auch nicht deine Instanz.",
|
||||
"safetyCodeChanged": "Die Schlüssel von {name} haben sich geändert, seit dieses Gerät sie zuletzt gesehen hat. Das passiert nach einem neuen Passwort ohne Wiederherstellungsphrase, oder wenn sich jemand dazwischen stellt. Vergleich den Code, bevor du ihnen vertraust.",
|
||||
"safetyCodeMarkCompared": "Als verglichen markieren",
|
||||
"safetyCodeTrustNew": "Den neuen Schlüsseln vertrauen",
|
||||
"studysetViewOnly": "Du kannst es ansehen und lernen, aber nicht ändern.",
|
||||
"studysetViewOnlyRefused": "Dieses Lernset kannst du nur ansehen."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2752,5 +2752,123 @@
|
|||
"publishMaterialOptions": "Choose with this",
|
||||
"@publishMaterialOptions": {"description": "Tooltip of a note's or file's menu in the step confirming the material"},
|
||||
"publishMaterialCount": "{count, plural, =0{No notes or files go} =1{1 note or file goes} other{{count} notes and files go}}",
|
||||
"@publishMaterialCount": {"description": "Below the material confirmed before publishing: how many go", "placeholders": {"count": {"type": "int"}}}
|
||||
"@publishMaterialCount": {"description": "Below the material confirmed before publishing: how many go", "placeholders": {"count": {"type": "int"}}},
|
||||
"studysetVisibilityShared": "Shared · private",
|
||||
"@studysetVisibilityShared": {"description": "The mark of a studyset shared with people, sealed for its members alone"},
|
||||
"shareTitle": "Share {title}",
|
||||
"@shareTitle": {"description": "Title of a studyset's Share sheet", "placeholders": {"title": {"type": "String"}}},
|
||||
"sharePeople": "People",
|
||||
"@sharePeople": {"description": "Heading of the Share sheet's section on people the studyset is shared with"},
|
||||
"sharePartOf": "Part of {title}, which is shared with these people:",
|
||||
"@sharePartOf": {"description": "Above the members, on a topic of a shared studyset", "placeholders": {"title": {"type": "String"}}},
|
||||
"sharePeopleIntro": "Study and write it together with others. They see it as you do, sealed for the people you invite alone.",
|
||||
"@sharePeopleIntro": {"description": "Explains sharing with people, before a studyset is shared"},
|
||||
"shareWithPeople": "Share with people",
|
||||
"@shareWithPeople": {"description": "Button starting to share a studyset with people"},
|
||||
"shareCommunity": "Community",
|
||||
"@shareCommunity": {"description": "Heading of the Share sheet's section on publishing"},
|
||||
"shareCommunityNotShared": "Not for studysets shared with people yet.",
|
||||
"@shareCommunityNotShared": {"description": "Why a shared studyset can't be published"},
|
||||
"shareAborted": "Sharing stopped. The studyset is as it was.",
|
||||
"@shareAborted": {"description": "Snack bar after sharing a studyset was aborted"},
|
||||
"shareWithPeopleTitle": "Share {title} with people",
|
||||
"@shareWithPeopleTitle": {"description": "Title of the sheet sharing a studyset with people", "placeholders": {"title": {"type": "String"}}},
|
||||
"shareWithPeopleConfirm": "Share",
|
||||
"@shareWithPeopleConfirm": {"description": "Confirms sharing a studyset with people"},
|
||||
"shareWithPeopleBody": "It moves with everything in it, your progress too, into a space of its own that you own. Then invite people by their handle. Topics stay inside it, and it can't be published while it is shared.",
|
||||
"@shareWithPeopleBody": {"description": "What sharing a studyset with people does, asked before it starts"},
|
||||
"shareRefusedPublished": "Something in it is published, or was. Studysets with a publication can't be shared yet.",
|
||||
"@shareRefusedPublished": {"description": "Why a studyset can't be shared"},
|
||||
"shareRefusedKeys": "This device doesn't hold your account's keys. Sign in with your password first.",
|
||||
"@shareRefusedKeys": {"description": "Why a studyset can't be shared"},
|
||||
"shareRefusedUnreached": "Your instance couldn't be reached. Try again once you're online.",
|
||||
"@shareRefusedUnreached": {"description": "Why a studyset couldn't be shared"},
|
||||
"shareFailed": "Sharing failed. The studyset is as it was.",
|
||||
"@shareFailed": {"description": "Sharing a studyset failed for another reason"},
|
||||
"workStageSpace": "Making its space",
|
||||
"@workStageSpace": {"description": "Stage of sharing a studyset"},
|
||||
"workStageCleaning": "Clearing the old copy",
|
||||
"@workStageCleaning": {"description": "Stage of sharing a studyset"},
|
||||
"shareMembersUnreachable": "Your instance couldn't be reached.",
|
||||
"@shareMembersUnreachable": {"description": "The members of a shared studyset couldn't be read or changed"},
|
||||
"shareInvite": "Invite",
|
||||
"@shareInvite": {"description": "Button inviting someone to a shared studyset"},
|
||||
"shareInviteTitle": "Invite someone",
|
||||
"@shareInviteTitle": {"description": "Title of the invite dialog"},
|
||||
"shareInviteHandle": "Their handle",
|
||||
"@shareInviteHandle": {"description": "Field for the handle of the person invited"},
|
||||
"shareRoleOwner": "Owner",
|
||||
"@shareRoleOwner": {"description": "A member's role in a shared studyset"},
|
||||
"shareRoleEditor": "Editor",
|
||||
"@shareRoleEditor": {"description": "A member's role in a shared studyset"},
|
||||
"shareRoleViewer": "Viewer",
|
||||
"@shareRoleViewer": {"description": "A member's role in a shared studyset"},
|
||||
"shareRoleEditorHint": "Studies it and changes it with you.",
|
||||
"@shareRoleEditorHint": {"description": "What an Editor may do"},
|
||||
"shareRoleViewerHint": "Studies it, but can't change it.",
|
||||
"@shareRoleViewerHint": {"description": "What a Viewer may do"},
|
||||
"shareYou": "you",
|
||||
"@shareYou": {"description": "Marks the person's own row among the members"},
|
||||
"shareInvited": "invited",
|
||||
"@shareInvited": {"description": "Marks a member who hasn't accepted yet"},
|
||||
"shareKeysChanged": "keys changed",
|
||||
"@shareKeysChanged": {"description": "Marks a member whose keys changed since this device saw them"},
|
||||
"shareCompared": "Safety code compared",
|
||||
"@shareCompared": {"description": "Marks a member whose safety code the person compared"},
|
||||
"shareMemberOptions": "Member options",
|
||||
"@shareMemberOptions": {"description": "Tooltip of a member's menu"},
|
||||
"shareMakeEditor": "Make editor",
|
||||
"@shareMakeEditor": {"description": "Gives a member the Editor role"},
|
||||
"shareMakeViewer": "Make viewer",
|
||||
"@shareMakeViewer": {"description": "Gives a member the Viewer role"},
|
||||
"shareMakeOwner": "Make owner",
|
||||
"@shareMakeOwner": {"description": "Hands the shared studyset on to a member"},
|
||||
"shareMakeOwnerTitle": "Make {name} its owner?",
|
||||
"@shareMakeOwnerTitle": {"description": "Asks before handing a shared studyset on", "placeholders": {"name": {"type": "String"}}},
|
||||
"shareMakeOwnerBody": "They decide who it is shared with from then on. You stay an editor.",
|
||||
"@shareMakeOwnerBody": {"description": "What handing a shared studyset on does"},
|
||||
"shareRemove": "Remove",
|
||||
"@shareRemove": {"description": "Removes a member from a shared studyset"},
|
||||
"shareRemoveTitle": "Remove {name}?",
|
||||
"@shareRemoveTitle": {"description": "Asks before removing a member", "placeholders": {"name": {"type": "String"}}},
|
||||
"shareRemoveBody": "They no longer see it or anything written in it from now on.",
|
||||
"@shareRemoveBody": {"description": "What removing a member does"},
|
||||
"shareLeave": "Leave",
|
||||
"@shareLeave": {"description": "Leaves a shared studyset"},
|
||||
"shareLeaveTitle": "Leave {title}?",
|
||||
"@shareLeaveTitle": {"description": "Asks before leaving a shared studyset", "placeholders": {"title": {"type": "String"}}},
|
||||
"shareLeaveBody": "It goes from your library with your progress in it. Someone has to invite you again to come back.",
|
||||
"@shareLeaveBody": {"description": "What leaving a shared studyset does"},
|
||||
"shareLeaveNextOwner": "Who owns it after you?",
|
||||
"@shareLeaveNextOwner": {"description": "Asks the owner who takes a shared studyset on as they leave"},
|
||||
"shareDeleteForEveryone": "Delete for everyone",
|
||||
"@shareDeleteForEveryone": {"description": "Deletes a shared studyset with everything in it, for every member"},
|
||||
"shareDeleteTitle": "Delete {title} for everyone?",
|
||||
"@shareDeleteTitle": {"description": "Asks before deleting a shared studyset", "placeholders": {"title": {"type": "String"}}},
|
||||
"shareDeleteBody": "It goes for good, with everything in it and everyone's progress, from every member's library. This can't be undone.",
|
||||
"@shareDeleteBody": {"description": "What deleting a shared studyset does"},
|
||||
"shareRefusedNoHandle": "Nobody on your instance has that handle.",
|
||||
"@shareRefusedNoHandle": {"description": "An invitation was refused"},
|
||||
"shareRefusedAlreadyMember": "They are a member or invited already.",
|
||||
"@shareRefusedAlreadyMember": {"description": "An invitation was refused"},
|
||||
"shareRefusedLastMember": "You're its only member: delete it instead.",
|
||||
"@shareRefusedLastMember": {"description": "Leaving was refused"},
|
||||
"shareRefusedUnavailable": "Your instance doesn't share studysets.",
|
||||
"@shareRefusedUnavailable": {"description": "The instance keeps no shared spaces"},
|
||||
"shareRefusedNotAllowed": "Only its owner can do that.",
|
||||
"@shareRefusedNotAllowed": {"description": "A change to a shared studyset was refused"},
|
||||
"safetyCodeTitle": "Safety code with {name}",
|
||||
"@safetyCodeTitle": {"description": "Title of the safety code sheet", "placeholders": {"name": {"type": "String"}}},
|
||||
"safetyCodeHowTo": "Compare this with the code {name} sees for you, in person or on a call. If they match, nobody stands between you, not even your instance.",
|
||||
"@safetyCodeHowTo": {"description": "How to use a safety code", "placeholders": {"name": {"type": "String"}}},
|
||||
"safetyCodeChanged": "{name}'s keys changed since this device last saw them. That happens after a new password without the recovery phrase, or if someone stands between you. Compare the code before trusting them.",
|
||||
"@safetyCodeChanged": {"description": "Warns that a member's keys changed", "placeholders": {"name": {"type": "String"}}},
|
||||
"safetyCodeMarkCompared": "Mark as compared",
|
||||
"@safetyCodeMarkCompared": {"description": "Marks a safety code compared"},
|
||||
"safetyCodeTrustNew": "Trust the new keys",
|
||||
"@safetyCodeTrustNew": {"description": "Trusts a member's changed keys after comparing"},
|
||||
"studysetViewOnly": "You can view and study this, not change it.",
|
||||
"@studysetViewOnly": {"description": "Line on a shared studyset where the person is a Viewer"},
|
||||
"studysetViewOnlyRefused": "You can only view this studyset.",
|
||||
"@studysetViewOnlyRefused": {"description": "A write in a studyset shared with the person as a Viewer was refused"}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13051,6 +13051,360 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =0{No notes or files go} =1{1 note or file goes} other{{count} notes and files go}}'**
|
||||
String publishMaterialCount(int count);
|
||||
|
||||
/// The mark of a studyset shared with people, sealed for its members alone
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Shared · private'**
|
||||
String get studysetVisibilityShared;
|
||||
|
||||
/// Title of a studyset's Share sheet
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Share {title}'**
|
||||
String shareTitle(String title);
|
||||
|
||||
/// Heading of the Share sheet's section on people the studyset is shared with
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'People'**
|
||||
String get sharePeople;
|
||||
|
||||
/// Above the members, on a topic of a shared studyset
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Part of {title}, which is shared with these people:'**
|
||||
String sharePartOf(String title);
|
||||
|
||||
/// Explains sharing with people, before a studyset is shared
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Study and write it together with others. They see it as you do, sealed for the people you invite alone.'**
|
||||
String get sharePeopleIntro;
|
||||
|
||||
/// Button starting to share a studyset with people
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Share with people'**
|
||||
String get shareWithPeople;
|
||||
|
||||
/// Heading of the Share sheet's section on publishing
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Community'**
|
||||
String get shareCommunity;
|
||||
|
||||
/// Why a shared studyset can't be published
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Not for studysets shared with people yet.'**
|
||||
String get shareCommunityNotShared;
|
||||
|
||||
/// Snack bar after sharing a studyset was aborted
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sharing stopped. The studyset is as it was.'**
|
||||
String get shareAborted;
|
||||
|
||||
/// Title of the sheet sharing a studyset with people
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Share {title} with people'**
|
||||
String shareWithPeopleTitle(String title);
|
||||
|
||||
/// Confirms sharing a studyset with people
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Share'**
|
||||
String get shareWithPeopleConfirm;
|
||||
|
||||
/// What sharing a studyset with people does, asked before it starts
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'It moves with everything in it, your progress too, into a space of its own that you own. Then invite people by their handle. Topics stay inside it, and it can\'t be published while it is shared.'**
|
||||
String get shareWithPeopleBody;
|
||||
|
||||
/// Why a studyset can't be shared
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Something in it is published, or was. Studysets with a publication can\'t be shared yet.'**
|
||||
String get shareRefusedPublished;
|
||||
|
||||
/// Why a studyset can't be shared
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This device doesn\'t hold your account\'s keys. Sign in with your password first.'**
|
||||
String get shareRefusedKeys;
|
||||
|
||||
/// Why a studyset couldn't be shared
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your instance couldn\'t be reached. Try again once you\'re online.'**
|
||||
String get shareRefusedUnreached;
|
||||
|
||||
/// Sharing a studyset failed for another reason
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sharing failed. The studyset is as it was.'**
|
||||
String get shareFailed;
|
||||
|
||||
/// Stage of sharing a studyset
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Making its space'**
|
||||
String get workStageSpace;
|
||||
|
||||
/// Stage of sharing a studyset
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Clearing the old copy'**
|
||||
String get workStageCleaning;
|
||||
|
||||
/// The members of a shared studyset couldn't be read or changed
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your instance couldn\'t be reached.'**
|
||||
String get shareMembersUnreachable;
|
||||
|
||||
/// Button inviting someone to a shared studyset
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Invite'**
|
||||
String get shareInvite;
|
||||
|
||||
/// Title of the invite dialog
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Invite someone'**
|
||||
String get shareInviteTitle;
|
||||
|
||||
/// Field for the handle of the person invited
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Their handle'**
|
||||
String get shareInviteHandle;
|
||||
|
||||
/// A member's role in a shared studyset
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Owner'**
|
||||
String get shareRoleOwner;
|
||||
|
||||
/// A member's role in a shared studyset
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Editor'**
|
||||
String get shareRoleEditor;
|
||||
|
||||
/// A member's role in a shared studyset
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Viewer'**
|
||||
String get shareRoleViewer;
|
||||
|
||||
/// What an Editor may do
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Studies it and changes it with you.'**
|
||||
String get shareRoleEditorHint;
|
||||
|
||||
/// What a Viewer may do
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Studies it, but can\'t change it.'**
|
||||
String get shareRoleViewerHint;
|
||||
|
||||
/// Marks the person's own row among the members
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'you'**
|
||||
String get shareYou;
|
||||
|
||||
/// Marks a member who hasn't accepted yet
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'invited'**
|
||||
String get shareInvited;
|
||||
|
||||
/// Marks a member whose keys changed since this device saw them
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'keys changed'**
|
||||
String get shareKeysChanged;
|
||||
|
||||
/// Marks a member whose safety code the person compared
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Safety code compared'**
|
||||
String get shareCompared;
|
||||
|
||||
/// Tooltip of a member's menu
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Member options'**
|
||||
String get shareMemberOptions;
|
||||
|
||||
/// Gives a member the Editor role
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Make editor'**
|
||||
String get shareMakeEditor;
|
||||
|
||||
/// Gives a member the Viewer role
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Make viewer'**
|
||||
String get shareMakeViewer;
|
||||
|
||||
/// Hands the shared studyset on to a member
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Make owner'**
|
||||
String get shareMakeOwner;
|
||||
|
||||
/// Asks before handing a shared studyset on
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Make {name} its owner?'**
|
||||
String shareMakeOwnerTitle(String name);
|
||||
|
||||
/// What handing a shared studyset on does
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'They decide who it is shared with from then on. You stay an editor.'**
|
||||
String get shareMakeOwnerBody;
|
||||
|
||||
/// Removes a member from a shared studyset
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove'**
|
||||
String get shareRemove;
|
||||
|
||||
/// Asks before removing a member
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove {name}?'**
|
||||
String shareRemoveTitle(String name);
|
||||
|
||||
/// What removing a member does
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'They no longer see it or anything written in it from now on.'**
|
||||
String get shareRemoveBody;
|
||||
|
||||
/// Leaves a shared studyset
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Leave'**
|
||||
String get shareLeave;
|
||||
|
||||
/// Asks before leaving a shared studyset
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Leave {title}?'**
|
||||
String shareLeaveTitle(String title);
|
||||
|
||||
/// What leaving a shared studyset does
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'It goes from your library with your progress in it. Someone has to invite you again to come back.'**
|
||||
String get shareLeaveBody;
|
||||
|
||||
/// Asks the owner who takes a shared studyset on as they leave
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Who owns it after you?'**
|
||||
String get shareLeaveNextOwner;
|
||||
|
||||
/// Deletes a shared studyset with everything in it, for every member
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete for everyone'**
|
||||
String get shareDeleteForEveryone;
|
||||
|
||||
/// Asks before deleting a shared studyset
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete {title} for everyone?'**
|
||||
String shareDeleteTitle(String title);
|
||||
|
||||
/// What deleting a shared studyset does
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'It goes for good, with everything in it and everyone\'s progress, from every member\'s library. This can\'t be undone.'**
|
||||
String get shareDeleteBody;
|
||||
|
||||
/// An invitation was refused
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Nobody on your instance has that handle.'**
|
||||
String get shareRefusedNoHandle;
|
||||
|
||||
/// An invitation was refused
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'They are a member or invited already.'**
|
||||
String get shareRefusedAlreadyMember;
|
||||
|
||||
/// Leaving was refused
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'You\'re its only member: delete it instead.'**
|
||||
String get shareRefusedLastMember;
|
||||
|
||||
/// The instance keeps no shared spaces
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your instance doesn\'t share studysets.'**
|
||||
String get shareRefusedUnavailable;
|
||||
|
||||
/// A change to a shared studyset was refused
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Only its owner can do that.'**
|
||||
String get shareRefusedNotAllowed;
|
||||
|
||||
/// Title of the safety code sheet
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Safety code with {name}'**
|
||||
String safetyCodeTitle(String name);
|
||||
|
||||
/// How to use a safety code
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Compare this with the code {name} sees for you, in person or on a call. If they match, nobody stands between you, not even your instance.'**
|
||||
String safetyCodeHowTo(String name);
|
||||
|
||||
/// Warns that a member's keys changed
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{name}\'s keys changed since this device last saw them. That happens after a new password without the recovery phrase, or if someone stands between you. Compare the code before trusting them.'**
|
||||
String safetyCodeChanged(String name);
|
||||
|
||||
/// Marks a safety code compared
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Mark as compared'**
|
||||
String get safetyCodeMarkCompared;
|
||||
|
||||
/// Trusts a member's changed keys after comparing
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Trust the new keys'**
|
||||
String get safetyCodeTrustNew;
|
||||
|
||||
/// Line on a shared studyset where the person is a Viewer
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'You can view and study this, not change it.'**
|
||||
String get studysetViewOnly;
|
||||
|
||||
/// A write in a studyset shared with the person as a Viewer was refused
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'You can only view this studyset.'**
|
||||
String get studysetViewOnlyRefused;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -8368,4 +8368,216 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get studysetVisibilityShared => 'Geteilt · privat';
|
||||
|
||||
@override
|
||||
String shareTitle(String title) {
|
||||
return '$title teilen';
|
||||
}
|
||||
|
||||
@override
|
||||
String get sharePeople => 'Personen';
|
||||
|
||||
@override
|
||||
String sharePartOf(String title) {
|
||||
return 'Teil von $title, das mit diesen Personen geteilt ist:';
|
||||
}
|
||||
|
||||
@override
|
||||
String get sharePeopleIntro =>
|
||||
'Lernt und schreibt es gemeinsam. Die anderen sehen es wie du, versiegelt nur für die Personen, die du einlädst.';
|
||||
|
||||
@override
|
||||
String get shareWithPeople => 'Mit Personen teilen';
|
||||
|
||||
@override
|
||||
String get shareCommunity => 'Community';
|
||||
|
||||
@override
|
||||
String get shareCommunityNotShared =>
|
||||
'Für Lernsets, die mit Personen geteilt sind, noch nicht.';
|
||||
|
||||
@override
|
||||
String get shareAborted => 'Teilen abgebrochen. Das Lernset ist wie vorher.';
|
||||
|
||||
@override
|
||||
String shareWithPeopleTitle(String title) {
|
||||
return '$title mit Personen teilen';
|
||||
}
|
||||
|
||||
@override
|
||||
String get shareWithPeopleConfirm => 'Teilen';
|
||||
|
||||
@override
|
||||
String get shareWithPeopleBody =>
|
||||
'Es zieht mit allem darin, auch deinem Fortschritt, in einen eigenen Bereich, der dir gehört. Dann lädst du Personen über ihr Handle ein. Themen bleiben darin, und solange es geteilt ist, lässt es sich nicht veröffentlichen.';
|
||||
|
||||
@override
|
||||
String get shareRefusedPublished =>
|
||||
'Etwas darin ist veröffentlicht oder war es. Lernsets mit einer Veröffentlichung lassen sich noch nicht teilen.';
|
||||
|
||||
@override
|
||||
String get shareRefusedKeys =>
|
||||
'Dieses Gerät hat die Schlüssel deines Kontos nicht. Melde dich zuerst mit deinem Passwort an.';
|
||||
|
||||
@override
|
||||
String get shareRefusedUnreached =>
|
||||
'Deine Instanz war nicht erreichbar. Versuch es noch einmal, sobald du online bist.';
|
||||
|
||||
@override
|
||||
String get shareFailed =>
|
||||
'Teilen ist fehlgeschlagen. Das Lernset ist wie vorher.';
|
||||
|
||||
@override
|
||||
String get workStageSpace => 'Den Bereich anlegen';
|
||||
|
||||
@override
|
||||
String get workStageCleaning => 'Die alte Kopie entfernen';
|
||||
|
||||
@override
|
||||
String get shareMembersUnreachable => 'Deine Instanz war nicht erreichbar.';
|
||||
|
||||
@override
|
||||
String get shareInvite => 'Einladen';
|
||||
|
||||
@override
|
||||
String get shareInviteTitle => 'Jemanden einladen';
|
||||
|
||||
@override
|
||||
String get shareInviteHandle => 'Handle der Person';
|
||||
|
||||
@override
|
||||
String get shareRoleOwner => 'Besitzer:in';
|
||||
|
||||
@override
|
||||
String get shareRoleEditor => 'Bearbeiter:in';
|
||||
|
||||
@override
|
||||
String get shareRoleViewer => 'Betrachter:in';
|
||||
|
||||
@override
|
||||
String get shareRoleEditorHint => 'Lernt es und ändert es mit dir.';
|
||||
|
||||
@override
|
||||
String get shareRoleViewerHint => 'Lernt es, kann es aber nicht ändern.';
|
||||
|
||||
@override
|
||||
String get shareYou => 'du';
|
||||
|
||||
@override
|
||||
String get shareInvited => 'eingeladen';
|
||||
|
||||
@override
|
||||
String get shareKeysChanged => 'Schlüssel geändert';
|
||||
|
||||
@override
|
||||
String get shareCompared => 'Sicherheitscode verglichen';
|
||||
|
||||
@override
|
||||
String get shareMemberOptions => 'Optionen für das Mitglied';
|
||||
|
||||
@override
|
||||
String get shareMakeEditor => 'Zur Bearbeiter:in machen';
|
||||
|
||||
@override
|
||||
String get shareMakeViewer => 'Zur Betrachter:in machen';
|
||||
|
||||
@override
|
||||
String get shareMakeOwner => 'Zur Besitzer:in machen';
|
||||
|
||||
@override
|
||||
String shareMakeOwnerTitle(String name) {
|
||||
return '$name zur Besitzer:in machen?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get shareMakeOwnerBody =>
|
||||
'Ab dann entscheidet diese Person, mit wem es geteilt ist. Du bleibst Bearbeiter:in.';
|
||||
|
||||
@override
|
||||
String get shareRemove => 'Entfernen';
|
||||
|
||||
@override
|
||||
String shareRemoveTitle(String name) {
|
||||
return '$name entfernen?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get shareRemoveBody =>
|
||||
'Diese Person sieht es und alles, was darin von nun an geschrieben wird, nicht mehr.';
|
||||
|
||||
@override
|
||||
String get shareLeave => 'Verlassen';
|
||||
|
||||
@override
|
||||
String shareLeaveTitle(String title) {
|
||||
return '$title verlassen?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get shareLeaveBody =>
|
||||
'Es verschwindet mit deinem Fortschritt darin aus deiner Bibliothek. Damit du zurückkommst, muss dich jemand erneut einladen.';
|
||||
|
||||
@override
|
||||
String get shareLeaveNextOwner => 'Wem gehört es nach dir?';
|
||||
|
||||
@override
|
||||
String get shareDeleteForEveryone => 'Für alle löschen';
|
||||
|
||||
@override
|
||||
String shareDeleteTitle(String title) {
|
||||
return '$title für alle löschen?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get shareDeleteBody =>
|
||||
'Es verschwindet endgültig, mit allem darin und dem Fortschritt aller, aus der Bibliothek jedes Mitglieds. Das lässt sich nicht rückgängig machen.';
|
||||
|
||||
@override
|
||||
String get shareRefusedNoHandle =>
|
||||
'Niemand auf deiner Instanz hat dieses Handle.';
|
||||
|
||||
@override
|
||||
String get shareRefusedAlreadyMember =>
|
||||
'Diese Person ist schon Mitglied oder eingeladen.';
|
||||
|
||||
@override
|
||||
String get shareRefusedLastMember =>
|
||||
'Du bist das einzige Mitglied: lösch es stattdessen.';
|
||||
|
||||
@override
|
||||
String get shareRefusedUnavailable => 'Deine Instanz teilt keine Lernsets.';
|
||||
|
||||
@override
|
||||
String get shareRefusedNotAllowed => 'Das kann nur die Besitzer:in.';
|
||||
|
||||
@override
|
||||
String safetyCodeTitle(String name) {
|
||||
return 'Sicherheitscode mit $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String safetyCodeHowTo(String name) {
|
||||
return 'Vergleich ihn mit dem Code, den $name für dich sieht, persönlich oder bei einem Anruf. Stimmen sie überein, steht niemand zwischen euch, auch nicht deine Instanz.';
|
||||
}
|
||||
|
||||
@override
|
||||
String safetyCodeChanged(String name) {
|
||||
return 'Die Schlüssel von $name haben sich geändert, seit dieses Gerät sie zuletzt gesehen hat. Das passiert nach einem neuen Passwort ohne Wiederherstellungsphrase, oder wenn sich jemand dazwischen stellt. Vergleich den Code, bevor du ihnen vertraust.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get safetyCodeMarkCompared => 'Als verglichen markieren';
|
||||
|
||||
@override
|
||||
String get safetyCodeTrustNew => 'Den neuen Schlüsseln vertrauen';
|
||||
|
||||
@override
|
||||
String get studysetViewOnly =>
|
||||
'Du kannst es ansehen und lernen, aber nicht ändern.';
|
||||
|
||||
@override
|
||||
String get studysetViewOnlyRefused => 'Dieses Lernset kannst du nur ansehen.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8294,6 +8294,216 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get studysetVisibilityShared => 'Shared · private';
|
||||
|
||||
@override
|
||||
String shareTitle(String title) {
|
||||
return 'Share $title';
|
||||
}
|
||||
|
||||
@override
|
||||
String get sharePeople => 'People';
|
||||
|
||||
@override
|
||||
String sharePartOf(String title) {
|
||||
return 'Part of $title, which is shared with these people:';
|
||||
}
|
||||
|
||||
@override
|
||||
String get sharePeopleIntro =>
|
||||
'Study and write it together with others. They see it as you do, sealed for the people you invite alone.';
|
||||
|
||||
@override
|
||||
String get shareWithPeople => 'Share with people';
|
||||
|
||||
@override
|
||||
String get shareCommunity => 'Community';
|
||||
|
||||
@override
|
||||
String get shareCommunityNotShared =>
|
||||
'Not for studysets shared with people yet.';
|
||||
|
||||
@override
|
||||
String get shareAborted => 'Sharing stopped. The studyset is as it was.';
|
||||
|
||||
@override
|
||||
String shareWithPeopleTitle(String title) {
|
||||
return 'Share $title with people';
|
||||
}
|
||||
|
||||
@override
|
||||
String get shareWithPeopleConfirm => 'Share';
|
||||
|
||||
@override
|
||||
String get shareWithPeopleBody =>
|
||||
'It moves with everything in it, your progress too, into a space of its own that you own. Then invite people by their handle. Topics stay inside it, and it can\'t be published while it is shared.';
|
||||
|
||||
@override
|
||||
String get shareRefusedPublished =>
|
||||
'Something in it is published, or was. Studysets with a publication can\'t be shared yet.';
|
||||
|
||||
@override
|
||||
String get shareRefusedKeys =>
|
||||
'This device doesn\'t hold your account\'s keys. Sign in with your password first.';
|
||||
|
||||
@override
|
||||
String get shareRefusedUnreached =>
|
||||
'Your instance couldn\'t be reached. Try again once you\'re online.';
|
||||
|
||||
@override
|
||||
String get shareFailed => 'Sharing failed. The studyset is as it was.';
|
||||
|
||||
@override
|
||||
String get workStageSpace => 'Making its space';
|
||||
|
||||
@override
|
||||
String get workStageCleaning => 'Clearing the old copy';
|
||||
|
||||
@override
|
||||
String get shareMembersUnreachable => 'Your instance couldn\'t be reached.';
|
||||
|
||||
@override
|
||||
String get shareInvite => 'Invite';
|
||||
|
||||
@override
|
||||
String get shareInviteTitle => 'Invite someone';
|
||||
|
||||
@override
|
||||
String get shareInviteHandle => 'Their handle';
|
||||
|
||||
@override
|
||||
String get shareRoleOwner => 'Owner';
|
||||
|
||||
@override
|
||||
String get shareRoleEditor => 'Editor';
|
||||
|
||||
@override
|
||||
String get shareRoleViewer => 'Viewer';
|
||||
|
||||
@override
|
||||
String get shareRoleEditorHint => 'Studies it and changes it with you.';
|
||||
|
||||
@override
|
||||
String get shareRoleViewerHint => 'Studies it, but can\'t change it.';
|
||||
|
||||
@override
|
||||
String get shareYou => 'you';
|
||||
|
||||
@override
|
||||
String get shareInvited => 'invited';
|
||||
|
||||
@override
|
||||
String get shareKeysChanged => 'keys changed';
|
||||
|
||||
@override
|
||||
String get shareCompared => 'Safety code compared';
|
||||
|
||||
@override
|
||||
String get shareMemberOptions => 'Member options';
|
||||
|
||||
@override
|
||||
String get shareMakeEditor => 'Make editor';
|
||||
|
||||
@override
|
||||
String get shareMakeViewer => 'Make viewer';
|
||||
|
||||
@override
|
||||
String get shareMakeOwner => 'Make owner';
|
||||
|
||||
@override
|
||||
String shareMakeOwnerTitle(String name) {
|
||||
return 'Make $name its owner?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get shareMakeOwnerBody =>
|
||||
'They decide who it is shared with from then on. You stay an editor.';
|
||||
|
||||
@override
|
||||
String get shareRemove => 'Remove';
|
||||
|
||||
@override
|
||||
String shareRemoveTitle(String name) {
|
||||
return 'Remove $name?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get shareRemoveBody =>
|
||||
'They no longer see it or anything written in it from now on.';
|
||||
|
||||
@override
|
||||
String get shareLeave => 'Leave';
|
||||
|
||||
@override
|
||||
String shareLeaveTitle(String title) {
|
||||
return 'Leave $title?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get shareLeaveBody =>
|
||||
'It goes from your library with your progress in it. Someone has to invite you again to come back.';
|
||||
|
||||
@override
|
||||
String get shareLeaveNextOwner => 'Who owns it after you?';
|
||||
|
||||
@override
|
||||
String get shareDeleteForEveryone => 'Delete for everyone';
|
||||
|
||||
@override
|
||||
String shareDeleteTitle(String title) {
|
||||
return 'Delete $title for everyone?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get shareDeleteBody =>
|
||||
'It goes for good, with everything in it and everyone\'s progress, from every member\'s library. This can\'t be undone.';
|
||||
|
||||
@override
|
||||
String get shareRefusedNoHandle => 'Nobody on your instance has that handle.';
|
||||
|
||||
@override
|
||||
String get shareRefusedAlreadyMember =>
|
||||
'They are a member or invited already.';
|
||||
|
||||
@override
|
||||
String get shareRefusedLastMember =>
|
||||
'You\'re its only member: delete it instead.';
|
||||
|
||||
@override
|
||||
String get shareRefusedUnavailable =>
|
||||
'Your instance doesn\'t share studysets.';
|
||||
|
||||
@override
|
||||
String get shareRefusedNotAllowed => 'Only its owner can do that.';
|
||||
|
||||
@override
|
||||
String safetyCodeTitle(String name) {
|
||||
return 'Safety code with $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String safetyCodeHowTo(String name) {
|
||||
return 'Compare this with the code $name sees for you, in person or on a call. If they match, nobody stands between you, not even your instance.';
|
||||
}
|
||||
|
||||
@override
|
||||
String safetyCodeChanged(String name) {
|
||||
return '$name\'s keys changed since this device last saw them. That happens after a new password without the recovery phrase, or if someone stands between you. Compare the code before trusting them.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get safetyCodeMarkCompared => 'Mark as compared';
|
||||
|
||||
@override
|
||||
String get safetyCodeTrustNew => 'Trust the new keys';
|
||||
|
||||
@override
|
||||
String get studysetViewOnly => 'You can view and study this, not change it.';
|
||||
|
||||
@override
|
||||
String get studysetViewOnlyRefused => 'You can only view this studyset.';
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ import 'package:halkyon_learn_flutter/src/feature/publish/data/published_store.d
|
|||
import 'package:halkyon_learn_flutter/src/feature/publish/data/serverpod_publish_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/sync_settings_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/sharing/data/sync_space_setup.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/studyset_deletions.dart';
|
||||
|
|
@ -519,13 +520,14 @@ Future<PersonDependencies> _openPersonData(
|
|||
// The keys of the studysets shared with the person, opened as members'
|
||||
// devices seal them, sealed to newcomers in turn, each member held to the
|
||||
// public keys first seen for them (Halkyon's ADR 0044, D3, D18, D20).
|
||||
final sharedKeys = SharedKeys(
|
||||
ServerpodSharedKeysGateway(client.modules.base),
|
||||
KeyPins(FileKeyPinStore(File(p.join(folder.path, 'key_pins.json')))),
|
||||
contentKeys.cipher,
|
||||
me: person.authUserId,
|
||||
);
|
||||
final spaceKeys = SharedSpaceKeys(
|
||||
SharedKeys(
|
||||
ServerpodSharedKeysGateway(client.modules.base),
|
||||
KeyPins(FileKeyPinStore(File(p.join(folder.path, 'key_pins.json')))),
|
||||
contentKeys.cipher,
|
||||
me: person.authUserId,
|
||||
),
|
||||
sharedKeys,
|
||||
contentKeys.cipher,
|
||||
() async => (await spaceLookup.sharedStudysets()).keys.toSet(),
|
||||
);
|
||||
|
|
@ -696,6 +698,15 @@ Future<PersonDependencies> _openPersonData(
|
|||
ServerpodGrammarGateway(client),
|
||||
() async => (await client.instance.info()).grammarLanguages,
|
||||
),
|
||||
sharedSpaces: ServerpodSharedSpaceGateway(client.modules.base),
|
||||
sharedKeys: sharedKeys,
|
||||
spaceSetup: SyncSpaceSetup(
|
||||
sync,
|
||||
spaceLookup,
|
||||
spaces,
|
||||
spaceKeys,
|
||||
() => contentKeys.canEncrypt,
|
||||
),
|
||||
onDispose: () async {
|
||||
try {
|
||||
// Deletions first: those still waiting are dropped, those under way
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_rep
|
|||
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_run_store.dart';
|
||||
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/sharing/data/studyset_sharing.dart'
|
||||
show SpaceSetup;
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.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';
|
||||
|
|
@ -120,6 +122,16 @@ class PersonDependencies({
|
|||
/// Spelling and grammar checked by the instance's LanguageTool (ADR
|
||||
/// 0024).
|
||||
required final GrammarChecks grammar,
|
||||
|
||||
/// The shared spaces the person is in, with their members (Halkyon's ADR
|
||||
/// 0044).
|
||||
required final SharedSpaceGateway sharedSpaces,
|
||||
|
||||
/// The shared spaces' keys and how this device sees each member's keys.
|
||||
required final SharedKeys sharedKeys,
|
||||
|
||||
/// A new space readied on this device as a studyset is shared.
|
||||
required final SpaceSetup spaceSetup,
|
||||
final Future<void> Function()? _onDispose,
|
||||
}) implements Disposable {
|
||||
/// The dependencies of the person signed in above [context]
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ abstract interface class SpaceLookup() {
|
|||
/// space; their personal space left out.
|
||||
Future<Map<String, Set<String>>> sharedStudysets();
|
||||
|
||||
/// The shared spaces the person only reads and studies, a Viewer.
|
||||
Future<Set<String>> readOnlySpaces();
|
||||
|
||||
/// The studyset of the card [id]; null for none here.
|
||||
Future<String?> studysetOfCard(String id);
|
||||
|
||||
|
|
@ -99,6 +102,9 @@ class SpaceRouter(final SpaceLookup _lookup) {
|
|||
Future<String?> _ofStudyset(String? studysetId) async =>
|
||||
studysetId == null ? null : (await _studysetSpaces())[studysetId];
|
||||
|
||||
/// The shared spaces the person only reads and studies, a Viewer.
|
||||
Future<Set<String>> readOnlySpaces() => _lookup.readOnlySpaces();
|
||||
|
||||
/// The shared space of each studyset in one, by its id.
|
||||
Future<Map<String, String>> studysetSpaces() async => {
|
||||
...await _studysetSpaces(),
|
||||
|
|
|
|||
|
|
@ -29,6 +29,15 @@ class SyncSpaceLookup(
|
|||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Set<String>> readOnlySpaces() async => {
|
||||
for (final grant in await OfflineSyncSpaceMembership.memberGrants(
|
||||
_db,
|
||||
protocol.UuidValue.withValidation(_person),
|
||||
))
|
||||
if (grant.role == OfflineSyncSpaceRole.readOnly) grant.uuidSpaceId.uuid,
|
||||
};
|
||||
|
||||
@override
|
||||
Future<String?> studysetOfCard(String id) async =>
|
||||
(await protocol.Card.db.findById(_db, _uuid(id)))?.studysetId.uuid;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
|
|
@ -5,6 +7,7 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/publish/bloc/publish_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/publish/widget/publish_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/sharing/widget/share_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_privacy.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
|
|
@ -13,8 +16,7 @@ import 'package:material_ui/material_ui.dart';
|
|||
|
||||
/// Share, in a studyset's action row beside Study and Add material: its
|
||||
/// icon says what the instance sees of the studyset, as the library's cards
|
||||
/// do ([visibilityIcon]), and opens what explains and changes it
|
||||
/// ([openVisibility]). Only an icon below medium windows, so the row fits a
|
||||
/// do ([visibilityIcon]), and opens its Share sheet ([openVisibility]). Only an icon below medium windows, so the row fits a
|
||||
/// phone's app bar.
|
||||
class const ShareButton({
|
||||
required final Studyset studyset,
|
||||
|
|
@ -49,9 +51,12 @@ class const ShareButton({
|
|||
/// is private (end-to-end encrypted, ADR 0019), published before or not,
|
||||
/// and a shield with a warning while something in it is still readable or
|
||||
/// 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.
|
||||
/// open lock while the instance can read it and it isn't. People for a
|
||||
/// shared studyset, always sealed (Halkyon's ADR 0044, D21, D31).
|
||||
IconData visibilityIcon(Studyset studyset, {required bool published}) =>
|
||||
studyset.isPrivate
|
||||
studyset.isShared
|
||||
? LucideIcons.users
|
||||
: studyset.isPrivate
|
||||
? (studyset.isPartlyReadable ? LucideIcons.shieldAlert : LucideIcons.lock)
|
||||
: published
|
||||
? LucideIcons.globe
|
||||
|
|
@ -62,7 +67,9 @@ String visibilityLabel(
|
|||
AppLocalizations l10n,
|
||||
Studyset studyset, {
|
||||
required bool published,
|
||||
}) => studyset.isPrivate
|
||||
}) => studyset.isShared
|
||||
? l10n.studysetVisibilityShared
|
||||
: studyset.isPrivate
|
||||
? (studyset.isPartlyReadable
|
||||
? l10n.studysetVisibilityPartly
|
||||
: l10n.studysetVisibilityPrivate)
|
||||
|
|
@ -70,22 +77,42 @@ String visibilityLabel(
|
|||
? l10n.studysetVisibilityPublic
|
||||
: l10n.studysetVisibilityReadable;
|
||||
|
||||
/// Opens what explains [studyset]'s marker and changes it: the Publish
|
||||
/// sheet once it is [published]; else the privacy dialog, which makes it
|
||||
/// private or not and, while it isn't private, leads to publishing it
|
||||
/// ([openPublish], the Publish sheet).
|
||||
/// Opens [studyset]'s Share sheet (Halkyon's ADR 0044, D29): the people it
|
||||
/// is shared with, then the community, whose row says what the instance
|
||||
/// sees of it and opens what explains and changes that: the Publish sheet
|
||||
/// once it is [published]; else the privacy dialog, which makes it private
|
||||
/// or not and, while it isn't private, leads to publishing it
|
||||
/// ([openPublish], the Publish sheet). [all] are the person's studysets.
|
||||
Future<void> openVisibility(
|
||||
BuildContext context, {
|
||||
required Studyset studyset,
|
||||
required List<Studyset> all,
|
||||
required bool published,
|
||||
required VoidCallback openPublish,
|
||||
}) async {
|
||||
if (published && !studyset.isPrivate) {
|
||||
openPublish();
|
||||
return;
|
||||
}
|
||||
await showStudysetPrivacy(context, studyset, onPublish: openPublish);
|
||||
}
|
||||
}) => showStudysetShare(
|
||||
context,
|
||||
studyset: studyset,
|
||||
all: all,
|
||||
communityIcon: studyset.isShared
|
||||
? LucideIcons.globe
|
||||
: visibilityIcon(studyset, published: published),
|
||||
communityLabel: studyset.isShared
|
||||
? AppLocalizations.of(context).shareCommunity
|
||||
: visibilityLabel(
|
||||
AppLocalizations.of(context),
|
||||
studyset,
|
||||
published: published,
|
||||
),
|
||||
openCommunity: () {
|
||||
if (published && !studyset.isPrivate) {
|
||||
openPublish();
|
||||
} else {
|
||||
unawaited(
|
||||
showStudysetPrivacy(context, studyset, onPublish: openPublish),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/// Opens the Publish sheet of [studyset] where no Share button is, as from
|
||||
/// a card in the library: with a [PublishBloc] of its own while it is open.
|
||||
|
|
@ -130,6 +157,7 @@ class const _Button({
|
|||
void open() => openVisibility(
|
||||
context,
|
||||
studyset: studyset,
|
||||
all: all,
|
||||
published: published,
|
||||
openPublish: () =>
|
||||
showPublishSheet(context, studyset: studyset, all: all),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
|
||||
/// What happened to something asked of a shared space: done, refused for a
|
||||
/// reason, or failed otherwise (no connection).
|
||||
sealed class const SpaceAction();
|
||||
|
||||
/// Done.
|
||||
final class const SpaceActionDone() extends SpaceAction;
|
||||
|
||||
/// Refused by the instance for [reason]; nothing changed.
|
||||
final class const SpaceActionRefused(final SpaceRefusal reason)
|
||||
extends SpaceAction;
|
||||
|
||||
/// Failed otherwise: no connection, most likely.
|
||||
final class const SpaceActionFailed() extends SpaceAction;
|
||||
|
||||
/// A shared space's members as its Share sheet shows them (Halkyon's ADR
|
||||
/// 0044, D29, D32): read from the instance, each with how this device sees
|
||||
/// their keys, and what the person asks of them, the members read again
|
||||
/// after each.
|
||||
class SpaceMembersCubit(
|
||||
final SharedSpaceGateway _spaces,
|
||||
final SharedKeys _keys,
|
||||
|
||||
/// The space.
|
||||
final String space,
|
||||
) extends Cubit<SpaceMembersState> {
|
||||
this : super(const SpaceMembersStateLoading());
|
||||
|
||||
/// Reads the members and their keys.
|
||||
Future<void> load() async {
|
||||
try {
|
||||
final members = await _spaces.members(space);
|
||||
final trust = <String, MemberTrust>{};
|
||||
try {
|
||||
for (final member in await _keys.trustIn(space)) {
|
||||
if (member.handle case final handle?) {
|
||||
trust[handle.toLowerCase()] = member;
|
||||
}
|
||||
}
|
||||
} on Object {
|
||||
// Without the keys here (no password on this device), the members
|
||||
// show without codes.
|
||||
}
|
||||
if (!isClosed) {
|
||||
emit(SpaceMembersStateReady(members: members, trust: trust));
|
||||
}
|
||||
} on Object {
|
||||
if (!isClosed) {
|
||||
emit(const SpaceMembersStateFailed());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Invites the person with [handle] as [role].
|
||||
Future<SpaceAction> invite(String handle, SpaceRole role) =>
|
||||
_run(() => _spaces.invite(space, handle.trim(), role));
|
||||
|
||||
/// Gives [member] the role [role].
|
||||
Future<SpaceAction> setRole(SpaceMember member, SpaceRole role) =>
|
||||
_run(() => _spaces.setRole(space, member.id, role));
|
||||
|
||||
/// Removes [member], or takes back their invitation.
|
||||
Future<SpaceAction> remove(SpaceMember member) =>
|
||||
_run(() => _spaces.remove(space, member.id));
|
||||
|
||||
/// Makes [member] the Owner, the person an Editor.
|
||||
Future<SpaceAction> handOn(SpaceMember member) =>
|
||||
_run(() => _spaces.handOn(space, member.id));
|
||||
|
||||
/// Leaves the space; its Owner names [nextOwner].
|
||||
Future<SpaceAction> leave({SpaceMember? nextOwner}) =>
|
||||
_run(() => _spaces.leave(space, nextOwner: nextOwner?.id), reload: false);
|
||||
|
||||
/// Deletes the space with everything in it; its Owner's.
|
||||
Future<SpaceAction> delete() =>
|
||||
_run(() => _spaces.delete(space), reload: false);
|
||||
|
||||
/// Marks [trust]'s keys compared, by their safety code.
|
||||
Future<void> markCompared(MemberTrust trust) async {
|
||||
await _keys.markCompared(trust.person);
|
||||
await load();
|
||||
}
|
||||
|
||||
/// Trusts [trust]'s new keys, whose code the person compared.
|
||||
Future<void> trustNewKeys(MemberTrust trust) async {
|
||||
await _keys.trustNewKeys(trust.person, trust.keys);
|
||||
await load();
|
||||
}
|
||||
|
||||
Future<SpaceAction> _run(
|
||||
Future<void> Function() call, {
|
||||
bool reload = true,
|
||||
}) async {
|
||||
final SpaceAction outcome;
|
||||
try {
|
||||
await call();
|
||||
outcome = const SpaceActionDone();
|
||||
} on SpaceRefused catch (refused) {
|
||||
return SpaceActionRefused(refused.reason);
|
||||
} on Object {
|
||||
return const SpaceActionFailed();
|
||||
}
|
||||
if (reload) {
|
||||
await load();
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a shared space's members are.
|
||||
sealed class const SpaceMembersState() with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Being read.
|
||||
final class const SpaceMembersStateLoading() extends SpaceMembersState;
|
||||
|
||||
/// Read: [members], the Owner first, and how this device sees the keys of
|
||||
/// those with a handle, by their handle in lower case.
|
||||
final class const SpaceMembersStateReady({
|
||||
required final List<SpaceMember> members,
|
||||
required final Map<String, MemberTrust> trust,
|
||||
}) extends SpaceMembersState {
|
||||
/// The person's own role there.
|
||||
SpaceRole? get yourRole =>
|
||||
members.where((member) => member.you).firstOrNull?.role;
|
||||
|
||||
/// How this device sees [member]'s keys; null for none known.
|
||||
MemberTrust? trustOf(SpaceMember member) =>
|
||||
member.handle == null ? null : trust[member.handle!.toLowerCase()];
|
||||
|
||||
@override
|
||||
List<Object?> get props => [members, trust];
|
||||
}
|
||||
|
||||
/// Not read: no connection, most likely.
|
||||
final class const SpaceMembersStateFailed() extends SpaceMembersState;
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/work/work_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/sharing/data/studyset_sharing.dart';
|
||||
|
||||
/// Sharing a studyset with people (Halkyon's ADR 0044, D14, D28): asked
|
||||
/// first, as it moves the studyset; then under way, its progress told,
|
||||
/// until done with the shared studyset's id, aborted or refused (what it
|
||||
/// made taken back), or failed.
|
||||
class StudysetSharingCubit(
|
||||
final StudysetSharing _sharing,
|
||||
final String _studysetId,
|
||||
) extends Cubit<StudysetSharingState> {
|
||||
this : super(const StudysetSharingStateAsking());
|
||||
|
||||
final _abort = AbortSignal();
|
||||
|
||||
/// Shares the studyset, as the person confirmed.
|
||||
Future<void> confirm() async {
|
||||
if (state is! StudysetSharingStateAsking) {
|
||||
return;
|
||||
}
|
||||
emit(const StudysetSharingStateRunning());
|
||||
try {
|
||||
final shared = await _sharing.share(
|
||||
_studysetId,
|
||||
abort: _abort,
|
||||
onProgress: (progress) => emit(
|
||||
StudysetSharingStateRunning(
|
||||
progress: progress,
|
||||
aborting: _abort.aborted,
|
||||
),
|
||||
),
|
||||
);
|
||||
emit(StudysetSharingStateDone(shared));
|
||||
} on WorkAborted {
|
||||
emit(const StudysetSharingStateAborted());
|
||||
} on SharingRefused catch (refused) {
|
||||
emit(StudysetSharingStateFailed(refused.reason));
|
||||
} on Object {
|
||||
emit(const StudysetSharingStateFailed(null));
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops the sharing under way, which then takes back what it made.
|
||||
void abort() {
|
||||
if (state case final StudysetSharingStateRunning running
|
||||
when !running.aborting) {
|
||||
_abort.abort();
|
||||
emit(
|
||||
StudysetSharingStateRunning(progress: running.progress, aborting: true),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where sharing a studyset is.
|
||||
sealed class const StudysetSharingState() with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Waiting for the person to confirm.
|
||||
final class const StudysetSharingStateAsking() extends StudysetSharingState;
|
||||
|
||||
/// Under way, as far as [progress] says; [aborting] once Abort was asked.
|
||||
final class const StudysetSharingStateRunning({
|
||||
final WorkProgress<SharingStage>? progress,
|
||||
final bool aborting = false,
|
||||
}) extends StudysetSharingState {
|
||||
@override
|
||||
List<Object?> get props => [progress, aborting];
|
||||
}
|
||||
|
||||
/// Shared: the studyset is now [studysetId], in its space.
|
||||
final class const StudysetSharingStateDone(final String studysetId)
|
||||
extends StudysetSharingState {
|
||||
@override
|
||||
List<Object?> get props => [studysetId];
|
||||
}
|
||||
|
||||
/// Aborted: nothing of it stays, and the studyset is as it was.
|
||||
final class const StudysetSharingStateAborted() extends StudysetSharingState;
|
||||
|
||||
/// Not shared, for [reason]; null where it failed otherwise (no connection,
|
||||
/// the instance refusing). The studyset is as it was.
|
||||
final class const StudysetSharingStateFailed(final SharingRefusal? reason)
|
||||
extends StudysetSharingState {
|
||||
@override
|
||||
List<Object?> get props => [reason];
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_importer.
|
|||
import 'package:halkyon_learn_flutter/src/feature/archive/model/import_archive.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/export/data/studyset_markdown.dart'
|
||||
show ExportFile;
|
||||
import 'package:halkyon_learn_flutter/src/feature/publish/data/publish_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/publish/model/publication.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
|
|
@ -82,7 +82,9 @@ class StudysetSharing(
|
|||
final SharedSpaceGateway _spaces,
|
||||
final SpaceSetup _setup,
|
||||
final StudysetRepository _studysets,
|
||||
final PublishGateway _publications,
|
||||
|
||||
/// The publication of a studyset; null for one never published.
|
||||
final Future<Publication?> Function(String studysetId) _publicationOf,
|
||||
final ArchiveExporter _exporter,
|
||||
|
||||
/// The exported files read back as an archive (packed, then read as an
|
||||
|
|
@ -114,7 +116,7 @@ class StudysetSharing(
|
|||
onProgress?.call(_stages.at(SharingStage.space));
|
||||
final tree = subtreeOf(studysetId, await _studysets.withDeleted());
|
||||
for (final id in tree) {
|
||||
if (await _publications.publication(id) != null) {
|
||||
if (await _publicationOf(id) != null) {
|
||||
throw const SharingRefused(SharingRefusal.published);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/sharing/bloc/space_members_cubit.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Shows the safety code of the person and the member [name] (Halkyon's
|
||||
/// ADR 0044, D18, D32): twelve groups of five digits, the same on both
|
||||
/// devices, to compare in person or on a call; then marks it compared, or
|
||||
/// where their keys changed, trusts the new ones the code was shown for.
|
||||
Future<void> showSafetyCode(
|
||||
BuildContext context, {
|
||||
required String name,
|
||||
required MemberTrust trust,
|
||||
required SpaceMembersCubit cubit,
|
||||
}) => showAdaptiveSheet<void>(
|
||||
context,
|
||||
builder: (context) => _SafetyCode(name: name, trust: trust, cubit: cubit),
|
||||
);
|
||||
|
||||
class const _SafetyCode({
|
||||
required final String name,
|
||||
required final MemberTrust trust,
|
||||
required final SpaceMembersCubit cubit,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final groups = trust.code.split(' ');
|
||||
final rows = [
|
||||
for (var i = 0; i < groups.length; i += 4)
|
||||
groups.skip(i).take(4).join(' '),
|
||||
];
|
||||
return SheetScaffold(
|
||||
title: l10n.safetyCodeTitle(name),
|
||||
footer: trust.changed
|
||||
? FilledButton.icon(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.error,
|
||||
foregroundColor: theme.colorScheme.onError,
|
||||
),
|
||||
onPressed: () async {
|
||||
await cubit.trustNewKeys(trust);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
icon: const Icon(LucideIcons.shieldCheck, size: 18),
|
||||
label: Text(l10n.safetyCodeTrustNew),
|
||||
)
|
||||
: trust.compared
|
||||
? null
|
||||
: FilledButton.icon(
|
||||
onPressed: () async {
|
||||
await cubit.markCompared(trust);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
icon: const Icon(LucideIcons.shieldCheck, size: 18),
|
||||
label: Text(l10n.safetyCodeMarkCompared),
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (trust.changed) ...[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(LucideIcons.shieldAlert, color: theme.colorScheme.error),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(l10n.safetyCodeChanged(name))),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
for (final row in rows)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: SelectableText(
|
||||
row,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(l10n.safetyCodeHowTo(name)),
|
||||
if (trust.compared && !trust.changed) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(LucideIcons.shieldCheck, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(l10n.shareCompared),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/sharing/bloc/space_members_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/sharing/widget/share_with_people.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/sharing/widget/space_members.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_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Opens [studyset]'s Share sheet (Halkyon's ADR 0044, D29): the people it
|
||||
/// is shared with first, or sharing it with people; then the community:
|
||||
/// what the instance sees of it ([communityIcon], [communityLabel]), which
|
||||
/// [openCommunity] explains and changes (privacy, publishing). [all] are the person's studysets.
|
||||
Future<void> showStudysetShare(
|
||||
BuildContext context, {
|
||||
required Studyset studyset,
|
||||
required List<Studyset> all,
|
||||
required IconData communityIcon,
|
||||
required String communityLabel,
|
||||
required VoidCallback openCommunity,
|
||||
}) async {
|
||||
final shared = await showAdaptiveSheet<String>(
|
||||
context,
|
||||
builder: (sheet) => _ShareSheet(
|
||||
studyset: studyset,
|
||||
all: all,
|
||||
communityIcon: communityIcon,
|
||||
communityLabel: communityLabel,
|
||||
openCommunity: () {
|
||||
Navigator.of(sheet).pop();
|
||||
openCommunity();
|
||||
},
|
||||
),
|
||||
);
|
||||
// Shared with people now: the studyset has new IDs (D14), and opens as
|
||||
// the top of its space (D23).
|
||||
if (shared != null && context.mounted) {
|
||||
GoRouter.of(context).go(RoutePaths.studyset(shared));
|
||||
}
|
||||
}
|
||||
|
||||
class const _ShareSheet({
|
||||
required final Studyset studyset,
|
||||
required final List<Studyset> all,
|
||||
required final IconData communityIcon,
|
||||
required final String communityLabel,
|
||||
required final VoidCallback openCommunity,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
Widget heading(String text) => Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
);
|
||||
final top = studyset.isShared
|
||||
? ancestorsOf(studyset.id, all).firstOrNull ?? studyset
|
||||
: studyset;
|
||||
return SheetScaffold(
|
||||
title: l10n.shareTitle(studyset.content.title),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
heading(l10n.sharePeople),
|
||||
if (studyset.sharedIn case final space?) ...[
|
||||
if (top.id != studyset.id)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(l10n.sharePartOf(top.content.title)),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) {
|
||||
final person = PersonDependencies.of(context);
|
||||
return SpaceMembersCubit(
|
||||
person.sharedSpaces,
|
||||
person.sharedKeys,
|
||||
space,
|
||||
)..load();
|
||||
},
|
||||
child: SpaceMembers(studyset: top),
|
||||
),
|
||||
] else ...[
|
||||
Text(l10n.sharePeopleIntro),
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: FilledButton.tonalIcon(
|
||||
onPressed: studyset.isLocked
|
||||
? null
|
||||
: () async {
|
||||
final shared = await shareWithPeople(
|
||||
context,
|
||||
studyset,
|
||||
);
|
||||
if (shared != null && context.mounted) {
|
||||
Navigator.of(context).pop(shared);
|
||||
}
|
||||
},
|
||||
icon: const Icon(LucideIcons.userPlus, size: 18),
|
||||
label: Text(l10n.shareWithPeople),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
const Divider(height: 1),
|
||||
heading(l10n.shareCommunity),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(communityIcon),
|
||||
title: Text(communityLabel),
|
||||
// Not for a shared studyset yet (D27).
|
||||
subtitle: studyset.isShared
|
||||
? Text(l10n.shareCommunityNotShared)
|
||||
: null,
|
||||
enabled: !studyset.isShared,
|
||||
trailing: studyset.isShared
|
||||
? null
|
||||
: const Icon(LucideIcons.chevronRight),
|
||||
onTap: studyset.isShared ? null : openCommunity,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes the Share sheet above [context] and returns to the library, on a
|
||||
/// studyset the person left or deleted: it goes with the next sync, which
|
||||
/// starts now.
|
||||
void backToLibrary(BuildContext context) {
|
||||
final sync = PersonDependencies.of(context).sync;
|
||||
final router = GoRouter.of(context);
|
||||
Navigator.of(context).pop();
|
||||
router.go(RoutePaths.library);
|
||||
sync.syncNow();
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.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/core/dependencies/person_dependencies.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/work_progress_view.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/work/work_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_exporter.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_importer.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/sharing/bloc/studyset_sharing_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/sharing/data/studyset_sharing.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Shares [studyset] with people (Halkyon's ADR 0044, D14, D23, D28): says
|
||||
/// what it does, then, confirmed, moves it into a space of its own, its
|
||||
/// progress told, with Abort. Completes with the shared studyset's id, or
|
||||
/// null where it wasn't shared (closed, aborted, refused, failed), the
|
||||
/// studyset then as it was.
|
||||
Future<String?> shareWithPeople(BuildContext context, Studyset studyset) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final ended = await showAdaptiveSheet<StudysetSharingState>(
|
||||
context,
|
||||
// Swiping away would leave it half moved, without asking.
|
||||
swipeable: false,
|
||||
builder: (context) => BlocProvider(
|
||||
create: (context) =>
|
||||
StudysetSharingCubit(studysetSharingOf(context), studyset.id),
|
||||
child: _Sharing(title: studyset.content.title),
|
||||
),
|
||||
);
|
||||
if (ended is StudysetSharingStateAborted) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(l10n.shareAborted)));
|
||||
}
|
||||
return switch (ended) {
|
||||
StudysetSharingStateDone(:final studysetId) => studysetId,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// [StudysetSharing] on the person's repositories above [context].
|
||||
StudysetSharing studysetSharingOf(BuildContext context) {
|
||||
final dependencies = Dependencies.of(context);
|
||||
final person = PersonDependencies.of(context);
|
||||
return StudysetSharing(
|
||||
person.sharedSpaces,
|
||||
person.spaceSetup,
|
||||
person.studysets,
|
||||
person.publishing.publication,
|
||||
ArchiveExporter(
|
||||
person.studysets,
|
||||
person.cards,
|
||||
person.notes,
|
||||
person.paths,
|
||||
person.pathProgress,
|
||||
person.pdfSources,
|
||||
person.tags,
|
||||
person.reviews,
|
||||
person.assets.fileOf,
|
||||
),
|
||||
(files) async => await dependencies.archives.read(
|
||||
await dependencies.exportArchiver.zip(files),
|
||||
),
|
||||
ArchiveImporter(
|
||||
ReleaseCopier(
|
||||
person.studysets,
|
||||
person.cards,
|
||||
person.notes,
|
||||
person.paths,
|
||||
person.pdfSources,
|
||||
person.tags,
|
||||
person.assets,
|
||||
),
|
||||
dependencies.dictionaries,
|
||||
person.reviews,
|
||||
person.pathProgress,
|
||||
person.studysets,
|
||||
person.pdfSources,
|
||||
person.deletions,
|
||||
person.assets,
|
||||
),
|
||||
person.deletions,
|
||||
);
|
||||
}
|
||||
|
||||
class const _Sharing({required final String title}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return BlocConsumer<StudysetSharingCubit, StudysetSharingState>(
|
||||
listenWhen: (previous, current) =>
|
||||
current is StudysetSharingStateDone ||
|
||||
current is StudysetSharingStateAborted,
|
||||
listener: (context, state) => Navigator.of(context).pop(state),
|
||||
builder: (context, state) {
|
||||
final running = state is StudysetSharingStateRunning;
|
||||
return SheetScaffold(
|
||||
title: l10n.shareWithPeopleTitle(title),
|
||||
// Closing would leave it half moved.
|
||||
busy: running,
|
||||
footer: switch (state) {
|
||||
StudysetSharingStateAsking() => FilledButton(
|
||||
onPressed: () => context.read<StudysetSharingCubit>().confirm(),
|
||||
child: Text(l10n.shareWithPeopleConfirm),
|
||||
),
|
||||
_ => null,
|
||||
},
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
switch (state) {
|
||||
StudysetSharingStateFailed(:final reason) => Text(
|
||||
switch (reason) {
|
||||
SharingRefusal.published => l10n.shareRefusedPublished,
|
||||
SharingRefusal.keysMissing => l10n.shareRefusedKeys,
|
||||
SharingRefusal.notReached => l10n.shareRefusedUnreached,
|
||||
null => l10n.shareFailed,
|
||||
},
|
||||
),
|
||||
_ => Text(l10n.shareWithPeopleBody),
|
||||
},
|
||||
if (state case StudysetSharingStateRunning(
|
||||
:final progress,
|
||||
:final aborting,
|
||||
)) ...[
|
||||
const SizedBox(height: 16),
|
||||
WorkProgressView(
|
||||
progress:
|
||||
progress ??
|
||||
const WorkProgress<SharingStage>(
|
||||
stage: SharingStage.space,
|
||||
step: 1,
|
||||
steps: 6,
|
||||
fraction: 0,
|
||||
),
|
||||
stageName: (stage) => switch (stage) {
|
||||
SharingStage.space => l10n.workStageSpace,
|
||||
SharingStage.reading => l10n.workStageReading,
|
||||
SharingStage.files => l10n.workStageFiles,
|
||||
SharingStage.content => l10n.workStageContent,
|
||||
SharingStage.progress => l10n.workStageProgress,
|
||||
SharingStage.cleaning => l10n.workStageCleaning,
|
||||
},
|
||||
onAbort: () => context.read<StudysetSharingCubit>().abort(),
|
||||
aborting: aborting,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,470 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/sharing/bloc/space_members_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/sharing/widget/safety_code_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/sharing/widget/share_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The members of a shared [studyset]'s space (Halkyon's ADR 0044, D2,
|
||||
/// D11, D29, D32), through the [SpaceMembersCubit] above: each with their
|
||||
/// role, which the Owner changes, and how this device sees their keys, a
|
||||
/// tap away from their safety code; inviting by @handle for the Owner;
|
||||
/// leaving, or for the Owner handing on first, and deleting it.
|
||||
class const SpaceMembers({super.key, required final Studyset studyset})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return BlocBuilder<SpaceMembersCubit, SpaceMembersState>(
|
||||
builder: (context, state) => switch (state) {
|
||||
SpaceMembersStateLoading() => const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(child: AppLoadingIndicator()),
|
||||
),
|
||||
SpaceMembersStateFailed() => Row(
|
||||
children: [
|
||||
Expanded(child: Text(l10n.shareMembersUnreachable)),
|
||||
TextButton(
|
||||
onPressed: () => context.read<SpaceMembersCubit>().load(),
|
||||
child: Text(l10n.commonTryAgain),
|
||||
),
|
||||
],
|
||||
),
|
||||
final SpaceMembersStateReady ready => _Ready(
|
||||
studyset: studyset,
|
||||
state: ready,
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class const _Ready({
|
||||
required final Studyset studyset,
|
||||
required final SpaceMembersStateReady state,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final owner = state.yourRole == SpaceRole.owner;
|
||||
final others = [
|
||||
for (final member in state.members)
|
||||
if (!member.you && !member.invited) member,
|
||||
];
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final member in state.members)
|
||||
_MemberRow(
|
||||
member: member,
|
||||
trust: state.trustOf(member),
|
||||
manage: owner && !member.you,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
if (owner)
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => _invite(context),
|
||||
icon: const Icon(LucideIcons.userPlus, size: 18),
|
||||
label: Text(l10n.shareInvite),
|
||||
),
|
||||
if (!owner || others.isNotEmpty)
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _leave(context, owner: owner, others: others),
|
||||
icon: const Icon(LucideIcons.logOut, size: 18),
|
||||
label: Text(l10n.shareLeave),
|
||||
),
|
||||
if (owner)
|
||||
TextButton.icon(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
onPressed: () => _delete(context),
|
||||
icon: const Icon(LucideIcons.trash2, size: 18),
|
||||
label: Text(l10n.shareDeleteForEveryone),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _invite(BuildContext context) async {
|
||||
final cubit = context.read<SpaceMembersCubit>();
|
||||
final asked = await showDialog<({String handle, SpaceRole role})>(
|
||||
context: context,
|
||||
builder: (context) => const _InviteDialog(),
|
||||
);
|
||||
if (asked != null && context.mounted) {
|
||||
final handle = asked.handle.startsWith('@')
|
||||
? asked.handle.substring(1)
|
||||
: asked.handle;
|
||||
final outcome = await cubit.invite(handle, asked.role);
|
||||
if (context.mounted) {
|
||||
_report(context, outcome);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _leave(
|
||||
BuildContext context, {
|
||||
required bool owner,
|
||||
required List<SpaceMember> others,
|
||||
}) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final cubit = context.read<SpaceMembersCubit>();
|
||||
SpaceMember? next;
|
||||
if (owner) {
|
||||
next = await showDialog<SpaceMember>(
|
||||
context: context,
|
||||
builder: (context) => SimpleDialog(
|
||||
title: Text(l10n.shareLeaveNextOwner),
|
||||
children: [
|
||||
for (final member in others)
|
||||
SimpleDialogOption(
|
||||
onPressed: () => Navigator.of(context).pop(member),
|
||||
child: Text(_nameOf(member)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (next == null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!context.mounted ||
|
||||
!await _confirm(
|
||||
context,
|
||||
title: l10n.shareLeaveTitle(studyset.content.title),
|
||||
body: l10n.shareLeaveBody,
|
||||
action: l10n.shareLeave,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
final outcome = await cubit.leave(nextOwner: next);
|
||||
if (context.mounted) {
|
||||
if (outcome is SpaceActionDone) {
|
||||
backToLibrary(context);
|
||||
} else {
|
||||
_report(context, outcome);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _delete(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final cubit = context.read<SpaceMembersCubit>();
|
||||
if (!await _confirm(
|
||||
context,
|
||||
title: l10n.shareDeleteTitle(studyset.content.title),
|
||||
body: l10n.shareDeleteBody,
|
||||
action: l10n.shareDeleteForEveryone,
|
||||
destroys: true,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
final outcome = await cubit.delete();
|
||||
if (context.mounted) {
|
||||
if (outcome is SpaceActionDone) {
|
||||
backToLibrary(context);
|
||||
} else {
|
||||
_report(context, outcome);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A member's row: who they are, their role (for the Owner, a menu
|
||||
/// changing it), and whether their keys changed or were compared.
|
||||
class const _MemberRow({
|
||||
required final SpaceMember member,
|
||||
required final MemberTrust? trust,
|
||||
required final bool manage,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final role = _roleName(l10n, member.role);
|
||||
final note = member.you
|
||||
? l10n.shareYou
|
||||
: member.invited
|
||||
? l10n.shareInvited
|
||||
: switch (trust) {
|
||||
MemberTrust(changed: true) => l10n.shareKeysChanged,
|
||||
MemberTrust(compared: true) => l10n.shareCompared,
|
||||
_ => null,
|
||||
};
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: CircleAvatar(
|
||||
child: Text(
|
||||
_nameOf(member).characters.firstOrNull?.toUpperCase() ?? '',
|
||||
),
|
||||
),
|
||||
title: Text(_nameOf(member)),
|
||||
subtitle: Text(
|
||||
[role, ?note].join(' · '),
|
||||
style: trust?.changed ?? false
|
||||
? TextStyle(color: theme.colorScheme.error)
|
||||
: null,
|
||||
),
|
||||
trailing: switch ((manage, trust)) {
|
||||
(true, _) => _RoleMenu(member: member),
|
||||
(false, MemberTrust(changed: true)) => Icon(
|
||||
LucideIcons.shieldAlert,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
(false, MemberTrust(compared: true)) => const Icon(
|
||||
LucideIcons.shieldCheck,
|
||||
),
|
||||
_ => null,
|
||||
},
|
||||
onTap: switch (trust) {
|
||||
final trust? when !member.you => () => showSafetyCode(
|
||||
context,
|
||||
name: _nameOf(member),
|
||||
trust: trust,
|
||||
cubit: context.read<SpaceMembersCubit>(),
|
||||
),
|
||||
_ => null,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the Owner may do with a member: give them another role, make them
|
||||
/// Owner, remove them.
|
||||
enum _MemberAction() {
|
||||
editor,
|
||||
viewer,
|
||||
owner,
|
||||
remove,
|
||||
}
|
||||
|
||||
class const _RoleMenu({required final SpaceMember member})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return PopupMenuButton<_MemberAction>(
|
||||
tooltip: l10n.shareMemberOptions,
|
||||
icon: const Icon(LucideIcons.ellipsisVertical),
|
||||
itemBuilder: (context) => [
|
||||
if (member.role != SpaceRole.editor)
|
||||
PopupMenuItem(
|
||||
value: _MemberAction.editor,
|
||||
child: Text(l10n.shareMakeEditor),
|
||||
),
|
||||
if (member.role != SpaceRole.viewer)
|
||||
PopupMenuItem(
|
||||
value: _MemberAction.viewer,
|
||||
child: Text(l10n.shareMakeViewer),
|
||||
),
|
||||
if (!member.invited)
|
||||
PopupMenuItem(
|
||||
value: _MemberAction.owner,
|
||||
child: Text(l10n.shareMakeOwner),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _MemberAction.remove,
|
||||
child: Text(
|
||||
l10n.shareRemove,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (action) async {
|
||||
final cubit = context.read<SpaceMembersCubit>();
|
||||
final asked = switch (action) {
|
||||
_MemberAction.owner => _confirm(
|
||||
context,
|
||||
title: l10n.shareMakeOwnerTitle(_nameOf(member)),
|
||||
body: l10n.shareMakeOwnerBody,
|
||||
action: l10n.shareMakeOwner,
|
||||
),
|
||||
_MemberAction.remove => _confirm(
|
||||
context,
|
||||
title: l10n.shareRemoveTitle(_nameOf(member)),
|
||||
body: l10n.shareRemoveBody,
|
||||
action: l10n.shareRemove,
|
||||
destroys: true,
|
||||
),
|
||||
_ => Future.value(true),
|
||||
};
|
||||
if (!await asked) {
|
||||
return;
|
||||
}
|
||||
final outcome = switch (action) {
|
||||
_MemberAction.editor => await cubit.setRole(member, SpaceRole.editor),
|
||||
_MemberAction.viewer => await cubit.setRole(member, SpaceRole.viewer),
|
||||
_MemberAction.owner => await cubit.handOn(member),
|
||||
_MemberAction.remove => await cubit.remove(member),
|
||||
};
|
||||
if (context.mounted) {
|
||||
_report(context, outcome);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks for a handle and a role to invite with, an Editor at first.
|
||||
class const _InviteDialog() extends StatefulWidget {
|
||||
@override
|
||||
State<_InviteDialog> createState() => _InviteDialogState();
|
||||
}
|
||||
|
||||
class _InviteDialogState() extends State<_InviteDialog> {
|
||||
final _handle = TextEditingController();
|
||||
var _role = SpaceRole.editor;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_handle.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
if (_handle.text.trim().isNotEmpty) {
|
||||
Navigator.of(context).pop((handle: _handle.text.trim(), role: _role));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return AlertDialog(
|
||||
title: Text(l10n.shareInviteTitle),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _handle,
|
||||
autofocus: true,
|
||||
autocorrect: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.shareInviteHandle,
|
||||
prefixText: '@',
|
||||
),
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SegmentedButton<SpaceRole>(
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: SpaceRole.editor,
|
||||
label: Text(l10n.shareRoleEditor),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: SpaceRole.viewer,
|
||||
label: Text(l10n.shareRoleViewer),
|
||||
),
|
||||
],
|
||||
selected: {_role},
|
||||
onSelectionChanged: (roles) => setState(() => _role = roles.single),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_role == SpaceRole.editor
|
||||
? l10n.shareRoleEditorHint
|
||||
: l10n.shareRoleViewerHint,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||
),
|
||||
ListenableBuilder(
|
||||
listenable: _handle,
|
||||
builder: (context, _) => FilledButton(
|
||||
onPressed: _handle.text.trim().isEmpty ? null : _submit,
|
||||
child: Text(l10n.shareInvite),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// [member] as the list names them: their display name, else their handle,
|
||||
/// else that they have no public profile.
|
||||
String _nameOf(SpaceMember member) => member.displayName.isNotEmpty
|
||||
? member.displayName
|
||||
: member.handle == null
|
||||
? '—'
|
||||
: '@${member.handle}';
|
||||
|
||||
String _roleName(AppLocalizations l10n, SpaceRole role) => switch (role) {
|
||||
SpaceRole.owner => l10n.shareRoleOwner,
|
||||
SpaceRole.editor => l10n.shareRoleEditor,
|
||||
SpaceRole.viewer => l10n.shareRoleViewer,
|
||||
};
|
||||
|
||||
/// Asks [title] with [body] before [action]; whether the person went on.
|
||||
Future<bool> _confirm(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String body,
|
||||
required String action,
|
||||
bool destroys = false,
|
||||
}) async =>
|
||||
await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(body),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||
),
|
||||
FilledButton(
|
||||
style: destroys
|
||||
? FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
foregroundColor: Theme.of(context).colorScheme.onError,
|
||||
)
|
||||
: null,
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(action),
|
||||
),
|
||||
],
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
|
||||
/// Says why [outcome] didn't happen, where it didn't.
|
||||
void _report(BuildContext context, SpaceAction outcome) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final message = switch (outcome) {
|
||||
SpaceActionDone() => null,
|
||||
SpaceActionFailed() => l10n.shareMembersUnreachable,
|
||||
SpaceActionRefused(:final reason) => switch (reason) {
|
||||
SpaceRefusal.noSuchHandle => l10n.shareRefusedNoHandle,
|
||||
SpaceRefusal.alreadyMember => l10n.shareRefusedAlreadyMember,
|
||||
SpaceRefusal.lastMember => l10n.shareRefusedLastMember,
|
||||
SpaceRefusal.unavailable => l10n.shareRefusedUnavailable,
|
||||
SpaceRefusal.notAllowed ||
|
||||
SpaceRefusal.ownerRole ||
|
||||
SpaceRefusal.self ||
|
||||
SpaceRefusal.nextOwnerNeeded ||
|
||||
SpaceRefusal.notFound => l10n.shareRefusedNotAllowed,
|
||||
},
|
||||
};
|
||||
if (message != null) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,10 @@ import 'package:halkyon_learn_flutter/src/core/privacy/model/unsigned_edit.dart'
|
|||
/// that key doesn't open on this device, its payload then blank;
|
||||
/// `fullyPrivate` while nothing in its tree was left readable or unsealed;
|
||||
/// `keysHere` where this device holds the account's keys.
|
||||
/// Where a shared studyset is: its space, and whether the person only views
|
||||
/// it there (a Viewer).
|
||||
typedef StudysetSharing = ({String space, bool viewOnly});
|
||||
|
||||
typedef StudysetRow = ({
|
||||
String id,
|
||||
ByteData payload,
|
||||
|
|
@ -33,6 +37,10 @@ abstract interface class StudysetGateway() {
|
|||
/// All studysets on this device, oldest first.
|
||||
Future<List<StudysetRow>> all();
|
||||
|
||||
/// The studysets in shared spaces (Halkyon's ADR 0044), by id: the space
|
||||
/// each is in, and whether the person only views it there.
|
||||
Future<Map<String, StudysetSharing>> sharing();
|
||||
|
||||
/// Stores a new studyset, made at [updatedAt], inside [parentId] (or at
|
||||
/// the top level) at [position] among its siblings, and returns its id, a
|
||||
/// UUID v7 made on the device, or [id] where given (a top studyset placed
|
||||
|
|
|
|||
|
|
@ -93,9 +93,11 @@ class StudysetRepository(final StudysetGateway _gateway) {
|
|||
|
||||
/// Every studyset on this device right now, those deleted and inside
|
||||
/// them too.
|
||||
Future<List<Studyset>> withDeleted() async => [
|
||||
for (final row in await _gateway.all()) ?_decode(row),
|
||||
];
|
||||
Future<List<Studyset>> withDeleted() async {
|
||||
final rows = await _gateway.all();
|
||||
final sharing = await _gateway.sharing();
|
||||
return [for (final row in rows) ?_decode(row, sharing[row.id])];
|
||||
}
|
||||
|
||||
/// Creates a studyset with [content] inside [parentId], after the
|
||||
/// studysets already there, or at the top level; returns its id, [id]
|
||||
|
|
@ -284,7 +286,7 @@ class StudysetRepository(final StudysetGateway _gateway) {
|
|||
/// The studyset in [row], or null for a row this app can't read: one
|
||||
/// written by a newer app version, or one being deleted. A private one
|
||||
/// whose key doesn't open here is there, locked, with no content.
|
||||
Studyset? _decode(StudysetRow row) {
|
||||
Studyset? _decode(StudysetRow row, StudysetSharing? sharing) {
|
||||
try {
|
||||
return Studyset(
|
||||
id: row.id,
|
||||
|
|
@ -301,6 +303,8 @@ class StudysetRepository(final StudysetGateway _gateway) {
|
|||
isLocked: row.locked,
|
||||
isPartlyReadable: row.isPrivate && !row.fullyPrivate,
|
||||
keysOnDevice: row.keysHere,
|
||||
sharedIn: sharing?.space,
|
||||
viewOnly: sharing?.viewOnly ?? false,
|
||||
);
|
||||
} on FormatException catch (error) {
|
||||
log('Skipped studyset ${row.id}: ${error.message}', name: 'Studysets');
|
||||
|
|
|
|||
|
|
@ -30,6 +30,20 @@ class SyncStudysetGateway(
|
|||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, StudysetSharing>> sharing() async {
|
||||
final spaces = _privacy.spaces;
|
||||
if (spaces == null) {
|
||||
return const {};
|
||||
}
|
||||
final readOnly = await spaces.readOnlySpaces();
|
||||
return {
|
||||
for (final MapEntry(key: studyset, value: space)
|
||||
in (await spaces.studysetSpaces()).entries)
|
||||
studyset: (space: space, viewOnly: readOnly.contains(space)),
|
||||
};
|
||||
}
|
||||
|
||||
StudysetRow _row(protocol.Studyset studyset, String? treeKey) {
|
||||
final id = studyset.id!.uuid;
|
||||
final opened = _privacy.openItem(
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue