feat(app,learn): invite to a shared studyset by email or link, and join
The apps' half of ADR 0044's step 5 (D37 to D40), on the instance's
half of the commit before.
halkyon_app's SharedSpaceGateway asks for inviting by mail, the
preview of a link, the code proving an address, accepting a mailed
invitation, a space's link (read, switched, joined) and marking a
share seen; SpaceInfo says whether a share is new and who made it,
a member whether they were invited by mail. joinTokenOf reads a link
or its token as pasted. The fake keeps the mails and codes it would
send, for the apps' tests.
In Learn:
- the Owner's Share sheet invites by handle or by email, lists an
invitation mailed by its role alone, and switches the space's link,
copying it while on;
- the Library's menu has Join with a link, and a link opened on
Android (/join/<token> on learn.halkyon.eu, verified through the
instance's assetlinks) opens the same on a screen of its own: who
invites and as whom, then Join, or for a mailed invitation the
address and the code mailed there;
- a studyset someone shared is new until opened: in the Inbox
("@alice shared Japanese with you", Open or Leave), on the Profile
tab's badge and as New on its library card (NewSharesCubit, read
again after every merge).
Whats-New: Invite people to a shared studyset by email or with a link, join one from the Library's Join with a link, and see in the Inbox what was shared with you.
Whats-New-De: Lade Leute per E-Mail oder Link zu einem geteilten Lernset ein, tritt einem über „Mit einem Link beitreten“ in der Bibliothek bei und sieh im Posteingang, was mit dir geteilt wurde.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
554e1604b4
commit
fb6b78fa19
24 changed files with 2443 additions and 54 deletions
|
|
@ -53,6 +53,19 @@
|
|||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<!-- Links to join a shared studyset (Halkyon's ADR 0044, D39), opened by the
|
||||
app itself (App Links): on learn.halkyon.eu, whose assetlinks.json names
|
||||
this app's key (deploy/Caddyfile); go_router takes /join/<token>. -->
|
||||
<meta-data android:name="flutter_deeplinking_enabled" android:value="true" />
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:scheme="https"
|
||||
android:host="learn.halkyon.eu"
|
||||
android:pathPrefix="/join/" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Receives the way back from SimpleLogin's sign-in (app.halkyon.learn://simplelogin-callback). -->
|
||||
<activity
|
||||
|
|
|
|||
|
|
@ -2270,5 +2270,43 @@
|
|||
"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."
|
||||
"studysetViewOnlyRefused": "Dieses Lernset kannst du nur ansehen.",
|
||||
"shareInviteByHandle": "Handle",
|
||||
"shareInviteByEmail": "E-Mail",
|
||||
"shareInviteAddress": "E-Mail-Adresse",
|
||||
"shareInviteEmailHint": "Sie bekommen einen Link per Mail. Beitreten kann nur, wer dieses Postfach liest, und die Instanz behält die Adresse nur als Fingerabdruck.",
|
||||
"shareInviteMailed": "Einladung an {address} geschickt",
|
||||
"shareInvitedByEmail": "Per E-Mail eingeladen",
|
||||
"shareLinkTitle": "Beitritt per Link",
|
||||
"shareLinkHint": "Wer den Link hat, kann als Betrachter:in beitreten. Ausgeschaltet gilt der Link nie wieder.",
|
||||
"shareLinkCopy": "Link kopieren",
|
||||
"shareLinkCopied": "Link kopiert",
|
||||
"shareRefusedNotAnAddress": "Das ist keine E-Mail-Adresse.",
|
||||
"shareRefusedTooManyMails": "Gerade zu viele Mails an diese Adresse. Versuch es in einer Stunde noch einmal.",
|
||||
"shareRefusedMailFailed": "Die Mail ließ sich nicht verschicken. Versuch es später noch einmal.",
|
||||
"shareRefusedHandleNeeded": "Eine Einladung per Mail nennt dich mit deinem Handle: Wähl zuerst eines in deinem Profil.",
|
||||
"libraryJoin": "Mit einem Link beitreten",
|
||||
"joinLinkLabel": "Link oder Code",
|
||||
"joinContinue": "Weiter",
|
||||
"joinInvites": "{inviter} möchte ein Lernset mit dir teilen",
|
||||
"joinSomeone": "Jemand",
|
||||
"joinAsViewer": "Du kannst es ansehen und damit lernen.",
|
||||
"joinAsEditor": "Du kannst es ansehen, damit lernen und es ändern.",
|
||||
"joinEmailHint": "Diese Einladung ging an eine E-Mail-Adresse. Gib sie ein, und du bekommst dorthin einen Code, der zeigt, dass sie dir gehört.",
|
||||
"joinSendCode": "Code senden",
|
||||
"joinCodeSent": "Ein Code ist unterwegs an {address}.",
|
||||
"joinCodeLabel": "Code aus der Mail",
|
||||
"joinAction": "Beitreten",
|
||||
"joinDone": "Du bist beigetreten. Das Lernset kommt mit der nächsten Synchronisierung in deine Bibliothek.",
|
||||
"joinAlreadyMember": "Du bist schon Mitglied.",
|
||||
"joinNotFound": "Dieser Link gilt nicht mehr: Er wurde abgeschaltet, ist abgelaufen oder wurde schon benutzt.",
|
||||
"joinWrongCode": "Dieser Code stimmt nicht.",
|
||||
"joinCodeExpired": "Dieser Code gilt nicht mehr. Lass dir einen neuen schicken.",
|
||||
"joinOtherAddress": "Diese Einladung ging an eine andere Adresse.",
|
||||
"inboxShared": "Mit dir geteilt",
|
||||
"inboxSharedBy": "@{handle} hat {title} mit dir geteilt",
|
||||
"inboxSharedNoHandle": "{title} wurde mit dir geteilt",
|
||||
"inboxSharedArriving": "Ein Lernset, das noch unterwegs ist",
|
||||
"inboxSharedOpen": "Öffnen",
|
||||
"studysetNew": "Neu"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2870,5 +2870,81 @@
|
|||
"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"}
|
||||
"@studysetViewOnlyRefused": {"description": "A write in a studyset shared with the person as a Viewer was refused"},
|
||||
"shareInviteByHandle": "Handle",
|
||||
"@shareInviteByHandle": {"description": "Inviting someone by their @handle, a choice in the invite dialog"},
|
||||
"shareInviteByEmail": "Email",
|
||||
"@shareInviteByEmail": {"description": "Inviting someone by email address, a choice in the invite dialog"},
|
||||
"shareInviteAddress": "Email address",
|
||||
"@shareInviteAddress": {"description": "Label of the invite dialog's address field"},
|
||||
"shareInviteEmailHint": "They get a link by mail. Only whoever reads that mailbox can join, and the instance keeps the address only as a fingerprint.",
|
||||
"@shareInviteEmailHint": {"description": "Below the address field of the invite dialog"},
|
||||
"shareInviteMailed": "Invitation mailed to {address}",
|
||||
"@shareInviteMailed": {"description": "Snack bar once an invitation by email went out", "placeholders": {"address": {"type": "String"}}},
|
||||
"shareInvitedByEmail": "Invited by email",
|
||||
"@shareInvitedByEmail": {"description": "A member row's name for an invitation mailed to an address, which members never see"},
|
||||
"shareLinkTitle": "Join by link",
|
||||
"@shareLinkTitle": {"description": "Switch on the Share sheet letting people join by the studyset's link"},
|
||||
"shareLinkHint": "Anyone with the link can join as a Viewer. Switching it off ends the link for good.",
|
||||
"@shareLinkHint": {"description": "Below the switch for joining by link"},
|
||||
"shareLinkCopy": "Copy link",
|
||||
"@shareLinkCopy": {"description": "Button copying the studyset's join link"},
|
||||
"shareLinkCopied": "Link copied",
|
||||
"@shareLinkCopied": {"description": "Snack bar once the join link is copied"},
|
||||
"shareRefusedNotAnAddress": "That isn't an email address.",
|
||||
"@shareRefusedNotAnAddress": {"description": "An invitation or code refused for an address that isn't one"},
|
||||
"shareRefusedTooManyMails": "Too many mails to that address for now. Try again in an hour.",
|
||||
"@shareRefusedTooManyMails": {"description": "An invitation or code refused as too many went out this hour"},
|
||||
"shareRefusedMailFailed": "The mail couldn't be sent. Try again later.",
|
||||
"@shareRefusedMailFailed": {"description": "An invitation or code whose mail failed"},
|
||||
"shareRefusedHandleNeeded": "An invitation by mail names you by your handle: choose one in your profile first.",
|
||||
"@shareRefusedHandleNeeded": {"description": "An invitation by email refused as the person has no handle"},
|
||||
"libraryJoin": "Join with a link",
|
||||
"@libraryJoin": {"description": "The Library's menu entry joining a studyset shared by link or invitation"},
|
||||
"joinLinkLabel": "Link or code",
|
||||
"@joinLinkLabel": {"description": "Field of the Join sheet taking a pasted link"},
|
||||
"joinContinue": "Continue",
|
||||
"@joinContinue": {"description": "Button looking up a pasted join link"},
|
||||
"joinInvites": "{inviter} wants to share a studyset with you",
|
||||
"@joinInvites": {"description": "What a join link offers", "placeholders": {"inviter": {"type": "String"}}},
|
||||
"joinSomeone": "Someone",
|
||||
"@joinSomeone": {"description": "Who invites, where they have no public profile"},
|
||||
"joinAsViewer": "You can view and study it.",
|
||||
"@joinAsViewer": {"description": "What joining as a Viewer gives"},
|
||||
"joinAsEditor": "You can view, study and change it.",
|
||||
"@joinAsEditor": {"description": "What joining as an Editor gives"},
|
||||
"joinEmailHint": "This invitation went to an email address. Type it, and you'll get a code there that shows it's yours.",
|
||||
"@joinEmailHint": {"description": "Above the address field of an invitation by email"},
|
||||
"joinSendCode": "Send code",
|
||||
"@joinSendCode": {"description": "Button mailing a code to accept an invitation"},
|
||||
"joinCodeSent": "A code is on its way to {address}.",
|
||||
"@joinCodeSent": {"description": "Once a code to join was mailed", "placeholders": {"address": {"type": "String"}}},
|
||||
"joinCodeLabel": "Code from the mail",
|
||||
"@joinCodeLabel": {"description": "Field taking the mailed code"},
|
||||
"joinAction": "Join",
|
||||
"@joinAction": {"description": "Button joining the shared studyset"},
|
||||
"joinDone": "You joined. The studyset arrives in your Library with the next sync.",
|
||||
"@joinDone": {"description": "Once joined"},
|
||||
"joinAlreadyMember": "You're a member already.",
|
||||
"@joinAlreadyMember": {"description": "A join link to a studyset the person is in already"},
|
||||
"joinNotFound": "This link holds no more: it was switched off, has lapsed or was used already.",
|
||||
"@joinNotFound": {"description": "A join link that holds no more"},
|
||||
"joinWrongCode": "That code isn't right.",
|
||||
"@joinWrongCode": {"description": "A mailed code typed wrong"},
|
||||
"joinCodeExpired": "This code is no longer valid. Send a new one.",
|
||||
"@joinCodeExpired": {"description": "A mailed code used, tried too often or too old"},
|
||||
"joinOtherAddress": "This invitation went to another address.",
|
||||
"@joinOtherAddress": {"description": "A code proving another address than the one invited"},
|
||||
"inboxShared": "Shared with you",
|
||||
"@inboxShared": {"description": "Heading of the Inbox's studysets shared with the person"},
|
||||
"inboxSharedBy": "@{handle} shared {title} with you",
|
||||
"@inboxSharedBy": {"description": "An Inbox entry of a studyset shared with the person", "placeholders": {"handle": {"type": "String"}, "title": {"type": "String"}}},
|
||||
"inboxSharedNoHandle": "{title} was shared with you",
|
||||
"@inboxSharedNoHandle": {"description": "An Inbox entry of a studyset shared by someone without a public profile", "placeholders": {"title": {"type": "String"}}},
|
||||
"inboxSharedArriving": "A studyset on its way",
|
||||
"@inboxSharedArriving": {"description": "The title of a studyset shared with the person that hasn't synced yet"},
|
||||
"inboxSharedOpen": "Open",
|
||||
"@inboxSharedOpen": {"description": "Button opening a studyset shared with the person"},
|
||||
"studysetNew": "New",
|
||||
"@studysetNew": {"description": "Mark on a library card of a studyset someone shared with the person, until opened"}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13405,6 +13405,234 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'You can only view this studyset.'**
|
||||
String get studysetViewOnlyRefused;
|
||||
|
||||
/// Inviting someone by their @handle, a choice in the invite dialog
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Handle'**
|
||||
String get shareInviteByHandle;
|
||||
|
||||
/// Inviting someone by email address, a choice in the invite dialog
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Email'**
|
||||
String get shareInviteByEmail;
|
||||
|
||||
/// Label of the invite dialog's address field
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Email address'**
|
||||
String get shareInviteAddress;
|
||||
|
||||
/// Below the address field of the invite dialog
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'They get a link by mail. Only whoever reads that mailbox can join, and the instance keeps the address only as a fingerprint.'**
|
||||
String get shareInviteEmailHint;
|
||||
|
||||
/// Snack bar once an invitation by email went out
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Invitation mailed to {address}'**
|
||||
String shareInviteMailed(String address);
|
||||
|
||||
/// A member row's name for an invitation mailed to an address, which members never see
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Invited by email'**
|
||||
String get shareInvitedByEmail;
|
||||
|
||||
/// Switch on the Share sheet letting people join by the studyset's link
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Join by link'**
|
||||
String get shareLinkTitle;
|
||||
|
||||
/// Below the switch for joining by link
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Anyone with the link can join as a Viewer. Switching it off ends the link for good.'**
|
||||
String get shareLinkHint;
|
||||
|
||||
/// Button copying the studyset's join link
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Copy link'**
|
||||
String get shareLinkCopy;
|
||||
|
||||
/// Snack bar once the join link is copied
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Link copied'**
|
||||
String get shareLinkCopied;
|
||||
|
||||
/// An invitation or code refused for an address that isn't one
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'That isn\'t an email address.'**
|
||||
String get shareRefusedNotAnAddress;
|
||||
|
||||
/// An invitation or code refused as too many went out this hour
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Too many mails to that address for now. Try again in an hour.'**
|
||||
String get shareRefusedTooManyMails;
|
||||
|
||||
/// An invitation or code whose mail failed
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The mail couldn\'t be sent. Try again later.'**
|
||||
String get shareRefusedMailFailed;
|
||||
|
||||
/// An invitation by email refused as the person has no handle
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'An invitation by mail names you by your handle: choose one in your profile first.'**
|
||||
String get shareRefusedHandleNeeded;
|
||||
|
||||
/// The Library's menu entry joining a studyset shared by link or invitation
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Join with a link'**
|
||||
String get libraryJoin;
|
||||
|
||||
/// Field of the Join sheet taking a pasted link
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Link or code'**
|
||||
String get joinLinkLabel;
|
||||
|
||||
/// Button looking up a pasted join link
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Continue'**
|
||||
String get joinContinue;
|
||||
|
||||
/// What a join link offers
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{inviter} wants to share a studyset with you'**
|
||||
String joinInvites(String inviter);
|
||||
|
||||
/// Who invites, where they have no public profile
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Someone'**
|
||||
String get joinSomeone;
|
||||
|
||||
/// What joining as a Viewer gives
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'You can view and study it.'**
|
||||
String get joinAsViewer;
|
||||
|
||||
/// What joining as an Editor gives
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'You can view, study and change it.'**
|
||||
String get joinAsEditor;
|
||||
|
||||
/// Above the address field of an invitation by email
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This invitation went to an email address. Type it, and you\'ll get a code there that shows it\'s yours.'**
|
||||
String get joinEmailHint;
|
||||
|
||||
/// Button mailing a code to accept an invitation
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Send code'**
|
||||
String get joinSendCode;
|
||||
|
||||
/// Once a code to join was mailed
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'A code is on its way to {address}.'**
|
||||
String joinCodeSent(String address);
|
||||
|
||||
/// Field taking the mailed code
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Code from the mail'**
|
||||
String get joinCodeLabel;
|
||||
|
||||
/// Button joining the shared studyset
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Join'**
|
||||
String get joinAction;
|
||||
|
||||
/// Once joined
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'You joined. The studyset arrives in your Library with the next sync.'**
|
||||
String get joinDone;
|
||||
|
||||
/// A join link to a studyset the person is in already
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'You\'re a member already.'**
|
||||
String get joinAlreadyMember;
|
||||
|
||||
/// A join link that holds no more
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This link holds no more: it was switched off, has lapsed or was used already.'**
|
||||
String get joinNotFound;
|
||||
|
||||
/// A mailed code typed wrong
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'That code isn\'t right.'**
|
||||
String get joinWrongCode;
|
||||
|
||||
/// A mailed code used, tried too often or too old
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This code is no longer valid. Send a new one.'**
|
||||
String get joinCodeExpired;
|
||||
|
||||
/// A code proving another address than the one invited
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This invitation went to another address.'**
|
||||
String get joinOtherAddress;
|
||||
|
||||
/// Heading of the Inbox's studysets shared with the person
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Shared with you'**
|
||||
String get inboxShared;
|
||||
|
||||
/// An Inbox entry of a studyset shared with the person
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'@{handle} shared {title} with you'**
|
||||
String inboxSharedBy(String handle, String title);
|
||||
|
||||
/// An Inbox entry of a studyset shared by someone without a public profile
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{title} was shared with you'**
|
||||
String inboxSharedNoHandle(String title);
|
||||
|
||||
/// The title of a studyset shared with the person that hasn't synced yet
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'A studyset on its way'**
|
||||
String get inboxSharedArriving;
|
||||
|
||||
/// Button opening a studyset shared with the person
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Open'**
|
||||
String get inboxSharedOpen;
|
||||
|
||||
/// Mark on a library card of a studyset someone shared with the person, until opened
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'New'**
|
||||
String get studysetNew;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -8580,4 +8580,138 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get studysetViewOnlyRefused => 'Dieses Lernset kannst du nur ansehen.';
|
||||
|
||||
@override
|
||||
String get shareInviteByHandle => 'Handle';
|
||||
|
||||
@override
|
||||
String get shareInviteByEmail => 'E-Mail';
|
||||
|
||||
@override
|
||||
String get shareInviteAddress => 'E-Mail-Adresse';
|
||||
|
||||
@override
|
||||
String get shareInviteEmailHint =>
|
||||
'Sie bekommen einen Link per Mail. Beitreten kann nur, wer dieses Postfach liest, und die Instanz behält die Adresse nur als Fingerabdruck.';
|
||||
|
||||
@override
|
||||
String shareInviteMailed(String address) {
|
||||
return 'Einladung an $address geschickt';
|
||||
}
|
||||
|
||||
@override
|
||||
String get shareInvitedByEmail => 'Per E-Mail eingeladen';
|
||||
|
||||
@override
|
||||
String get shareLinkTitle => 'Beitritt per Link';
|
||||
|
||||
@override
|
||||
String get shareLinkHint =>
|
||||
'Wer den Link hat, kann als Betrachter:in beitreten. Ausgeschaltet gilt der Link nie wieder.';
|
||||
|
||||
@override
|
||||
String get shareLinkCopy => 'Link kopieren';
|
||||
|
||||
@override
|
||||
String get shareLinkCopied => 'Link kopiert';
|
||||
|
||||
@override
|
||||
String get shareRefusedNotAnAddress => 'Das ist keine E-Mail-Adresse.';
|
||||
|
||||
@override
|
||||
String get shareRefusedTooManyMails =>
|
||||
'Gerade zu viele Mails an diese Adresse. Versuch es in einer Stunde noch einmal.';
|
||||
|
||||
@override
|
||||
String get shareRefusedMailFailed =>
|
||||
'Die Mail ließ sich nicht verschicken. Versuch es später noch einmal.';
|
||||
|
||||
@override
|
||||
String get shareRefusedHandleNeeded =>
|
||||
'Eine Einladung per Mail nennt dich mit deinem Handle: Wähl zuerst eines in deinem Profil.';
|
||||
|
||||
@override
|
||||
String get libraryJoin => 'Mit einem Link beitreten';
|
||||
|
||||
@override
|
||||
String get joinLinkLabel => 'Link oder Code';
|
||||
|
||||
@override
|
||||
String get joinContinue => 'Weiter';
|
||||
|
||||
@override
|
||||
String joinInvites(String inviter) {
|
||||
return '$inviter möchte ein Lernset mit dir teilen';
|
||||
}
|
||||
|
||||
@override
|
||||
String get joinSomeone => 'Jemand';
|
||||
|
||||
@override
|
||||
String get joinAsViewer => 'Du kannst es ansehen und damit lernen.';
|
||||
|
||||
@override
|
||||
String get joinAsEditor =>
|
||||
'Du kannst es ansehen, damit lernen und es ändern.';
|
||||
|
||||
@override
|
||||
String get joinEmailHint =>
|
||||
'Diese Einladung ging an eine E-Mail-Adresse. Gib sie ein, und du bekommst dorthin einen Code, der zeigt, dass sie dir gehört.';
|
||||
|
||||
@override
|
||||
String get joinSendCode => 'Code senden';
|
||||
|
||||
@override
|
||||
String joinCodeSent(String address) {
|
||||
return 'Ein Code ist unterwegs an $address.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get joinCodeLabel => 'Code aus der Mail';
|
||||
|
||||
@override
|
||||
String get joinAction => 'Beitreten';
|
||||
|
||||
@override
|
||||
String get joinDone =>
|
||||
'Du bist beigetreten. Das Lernset kommt mit der nächsten Synchronisierung in deine Bibliothek.';
|
||||
|
||||
@override
|
||||
String get joinAlreadyMember => 'Du bist schon Mitglied.';
|
||||
|
||||
@override
|
||||
String get joinNotFound =>
|
||||
'Dieser Link gilt nicht mehr: Er wurde abgeschaltet, ist abgelaufen oder wurde schon benutzt.';
|
||||
|
||||
@override
|
||||
String get joinWrongCode => 'Dieser Code stimmt nicht.';
|
||||
|
||||
@override
|
||||
String get joinCodeExpired =>
|
||||
'Dieser Code gilt nicht mehr. Lass dir einen neuen schicken.';
|
||||
|
||||
@override
|
||||
String get joinOtherAddress => 'Diese Einladung ging an eine andere Adresse.';
|
||||
|
||||
@override
|
||||
String get inboxShared => 'Mit dir geteilt';
|
||||
|
||||
@override
|
||||
String inboxSharedBy(String handle, String title) {
|
||||
return '@$handle hat $title mit dir geteilt';
|
||||
}
|
||||
|
||||
@override
|
||||
String inboxSharedNoHandle(String title) {
|
||||
return '$title wurde mit dir geteilt';
|
||||
}
|
||||
|
||||
@override
|
||||
String get inboxSharedArriving => 'Ein Lernset, das noch unterwegs ist';
|
||||
|
||||
@override
|
||||
String get inboxSharedOpen => 'Öffnen';
|
||||
|
||||
@override
|
||||
String get studysetNew => 'Neu';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8504,6 +8504,138 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get studysetViewOnlyRefused => 'You can only view this studyset.';
|
||||
|
||||
@override
|
||||
String get shareInviteByHandle => 'Handle';
|
||||
|
||||
@override
|
||||
String get shareInviteByEmail => 'Email';
|
||||
|
||||
@override
|
||||
String get shareInviteAddress => 'Email address';
|
||||
|
||||
@override
|
||||
String get shareInviteEmailHint =>
|
||||
'They get a link by mail. Only whoever reads that mailbox can join, and the instance keeps the address only as a fingerprint.';
|
||||
|
||||
@override
|
||||
String shareInviteMailed(String address) {
|
||||
return 'Invitation mailed to $address';
|
||||
}
|
||||
|
||||
@override
|
||||
String get shareInvitedByEmail => 'Invited by email';
|
||||
|
||||
@override
|
||||
String get shareLinkTitle => 'Join by link';
|
||||
|
||||
@override
|
||||
String get shareLinkHint =>
|
||||
'Anyone with the link can join as a Viewer. Switching it off ends the link for good.';
|
||||
|
||||
@override
|
||||
String get shareLinkCopy => 'Copy link';
|
||||
|
||||
@override
|
||||
String get shareLinkCopied => 'Link copied';
|
||||
|
||||
@override
|
||||
String get shareRefusedNotAnAddress => 'That isn\'t an email address.';
|
||||
|
||||
@override
|
||||
String get shareRefusedTooManyMails =>
|
||||
'Too many mails to that address for now. Try again in an hour.';
|
||||
|
||||
@override
|
||||
String get shareRefusedMailFailed =>
|
||||
'The mail couldn\'t be sent. Try again later.';
|
||||
|
||||
@override
|
||||
String get shareRefusedHandleNeeded =>
|
||||
'An invitation by mail names you by your handle: choose one in your profile first.';
|
||||
|
||||
@override
|
||||
String get libraryJoin => 'Join with a link';
|
||||
|
||||
@override
|
||||
String get joinLinkLabel => 'Link or code';
|
||||
|
||||
@override
|
||||
String get joinContinue => 'Continue';
|
||||
|
||||
@override
|
||||
String joinInvites(String inviter) {
|
||||
return '$inviter wants to share a studyset with you';
|
||||
}
|
||||
|
||||
@override
|
||||
String get joinSomeone => 'Someone';
|
||||
|
||||
@override
|
||||
String get joinAsViewer => 'You can view and study it.';
|
||||
|
||||
@override
|
||||
String get joinAsEditor => 'You can view, study and change it.';
|
||||
|
||||
@override
|
||||
String get joinEmailHint =>
|
||||
'This invitation went to an email address. Type it, and you\'ll get a code there that shows it\'s yours.';
|
||||
|
||||
@override
|
||||
String get joinSendCode => 'Send code';
|
||||
|
||||
@override
|
||||
String joinCodeSent(String address) {
|
||||
return 'A code is on its way to $address.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get joinCodeLabel => 'Code from the mail';
|
||||
|
||||
@override
|
||||
String get joinAction => 'Join';
|
||||
|
||||
@override
|
||||
String get joinDone =>
|
||||
'You joined. The studyset arrives in your Library with the next sync.';
|
||||
|
||||
@override
|
||||
String get joinAlreadyMember => 'You\'re a member already.';
|
||||
|
||||
@override
|
||||
String get joinNotFound =>
|
||||
'This link holds no more: it was switched off, has lapsed or was used already.';
|
||||
|
||||
@override
|
||||
String get joinWrongCode => 'That code isn\'t right.';
|
||||
|
||||
@override
|
||||
String get joinCodeExpired => 'This code is no longer valid. Send a new one.';
|
||||
|
||||
@override
|
||||
String get joinOtherAddress => 'This invitation went to another address.';
|
||||
|
||||
@override
|
||||
String get inboxShared => 'Shared with you';
|
||||
|
||||
@override
|
||||
String inboxSharedBy(String handle, String title) {
|
||||
return '@$handle shared $title with you';
|
||||
}
|
||||
|
||||
@override
|
||||
String inboxSharedNoHandle(String title) {
|
||||
return '$title was shared with you';
|
||||
}
|
||||
|
||||
@override
|
||||
String get inboxSharedArriving => 'A studyset on its way';
|
||||
|
||||
@override
|
||||
String get inboxSharedOpen => 'Open';
|
||||
|
||||
@override
|
||||
String get studysetNew => 'New';
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ import 'package:halkyon_learn_flutter/src/feature/note/widget/note_editor_screen
|
|||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/pdf_reader_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/reading_timeline_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/widget/settings_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/sharing/bloc/new_shares_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/sharing/widget/join_space.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/sharing/widget/marks_share_seen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/bloc/study_session_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_focus.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_screen.dart';
|
||||
|
|
@ -59,7 +62,11 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
|
|||
hidesNavigationBar: RoutePaths.isInsideStudyset(state.uri.path),
|
||||
// The inbox's threads with something new.
|
||||
profileBadge: (context) =>
|
||||
context.select<InboxCountBloc?, int>((bloc) => bloc?.state ?? 0),
|
||||
context.select<InboxCountBloc?, int>((bloc) => bloc?.state ?? 0) +
|
||||
// And the studysets shared with the person, new to them (D40).
|
||||
context.select<NewSharesCubit?, int>(
|
||||
(cubit) => cubit?.state.length ?? 0,
|
||||
),
|
||||
),
|
||||
navigatorContainerBuilder: tabsContainer,
|
||||
// In the order of the shell's destinations.
|
||||
|
|
@ -323,6 +330,14 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
|
|||
path: RoutePaths.settings,
|
||||
builder: (context, state) => const FramedScreen(child: SettingsScreen()),
|
||||
),
|
||||
// A link to join a shared studyset, as the app opens it (Halkyon's ADR
|
||||
// 0044, D39): the page's own address.
|
||||
GoRoute(
|
||||
path: '/join/:token',
|
||||
builder: (context, state) => FramedScreen(
|
||||
child: JoinScreen(token: state.pathParameters['token']!),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -433,7 +448,11 @@ Widget _inStudyset(
|
|||
child: StudysetAccess(
|
||||
shared: studyset.isShared,
|
||||
viewOnly: studyset.viewOnly,
|
||||
child: builder(studyset, all),
|
||||
// Opened, a studyset someone shared is no longer new (D40).
|
||||
child: MarksShareSeen(
|
||||
space: studyset.sharedIn,
|
||||
child: builder(studyset, all),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import 'package:halkyon_learn_flutter/src/feature/assistant/data/settings_own_as
|
|||
import 'package:halkyon_learn_flutter/src/feature/feedback/bloc/inbox_count_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pomodoro/bloc/pomodoro_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pomodoro/widget/pomodoro_presenter.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/sharing/bloc/new_shares_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_button_block.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
|
@ -120,6 +121,18 @@ class _PersonAppState() extends State<PersonApp> {
|
|||
PersonDependencies.of(context).discussions,
|
||||
)..add(const InboxCountEventRefreshed()),
|
||||
),
|
||||
// The studysets shared with the person, new to them
|
||||
// (Halkyon's ADR 0044, D40): the Inbox, the badge, the
|
||||
// library's cards.
|
||||
BlocProvider(
|
||||
create: (context) {
|
||||
final person = PersonDependencies.of(context);
|
||||
return NewSharesCubit(
|
||||
person.sharedSpaces,
|
||||
person.sync.merges,
|
||||
)..refresh();
|
||||
},
|
||||
),
|
||||
// How many accounts wait, for whoever operates the
|
||||
// instance: Settings' card and the badge on its cogs.
|
||||
BlocProvider(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/discussion/widget/discussion_inbox.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/feedback/widget/inbox_section.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/sharing/widget/shared_with_you.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/statistics/widget/statistics_view.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
|
@ -31,7 +32,12 @@ class const ProfileScreen({super.key}) extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
sections: const [InboxSection(discussions: DiscussionInbox())],
|
||||
sections: const [
|
||||
InboxSection(
|
||||
shared: SharedWithYou(),
|
||||
discussions: DiscussionInbox(),
|
||||
),
|
||||
],
|
||||
tabs: [
|
||||
FoldingTab(
|
||||
label: l10n.profileTabStatistics,
|
||||
|
|
|
|||
|
|
@ -6,13 +6,17 @@ import 'package:halkyon_learn_flutter/src/feature/feedback/widget/feedback_actio
|
|||
import 'package:halkyon_learn_flutter/src/feature/feedback/widget/feedback_threads.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The person's inbox (ADR 0005, rule 6), in the Profile tab: the
|
||||
/// [discussions] they follow (ADR 0013, given by the app, as this feature
|
||||
/// can't know them), then the reports and suggestions on their published
|
||||
/// The person's inbox (ADR 0005, rule 6), in the Profile tab: what was
|
||||
/// [shared] with them and is new to them (Halkyon's ADR 0044, D40), the
|
||||
/// [discussions] they follow (ADR 0013; both given by the app, as this
|
||||
/// feature can't know them), then the reports and suggestions on their published
|
||||
/// studysets and those they wrote, the most recently active first, those
|
||||
/// with something new marked; for people with an identity.
|
||||
class const InboxSection({required final Widget discussions, super.key})
|
||||
extends StatelessWidget {
|
||||
class const InboxSection({
|
||||
required final Widget shared,
|
||||
required final Widget discussions,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
|
@ -38,6 +42,7 @@ class const InboxSection({required final Widget discussions, super.key})
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
shared,
|
||||
Text(l10n.inboxDiscussions, style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
discussions,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
|
||||
/// Joining a shared studyset by a link (Halkyon's ADR 0044, D37, D39): the
|
||||
/// link pasted, or opened, looked up; a space's own link joined at once, an
|
||||
/// invitation mailed once the person proves its address with a code mailed
|
||||
/// there. What the instance refuses is said, and the person tries again
|
||||
/// from where they were.
|
||||
class JoinSpaceCubit(
|
||||
final SharedSpaceGateway _spaces,
|
||||
|
||||
/// Asks for a sync, which brings the space joined.
|
||||
final void Function() _requestSync,
|
||||
) extends Cubit<JoinSpaceState> {
|
||||
this : super(const JoinSpaceState());
|
||||
|
||||
/// Looks up what [pasted], a link or its token, invites to.
|
||||
Future<void> look(String pasted) async {
|
||||
final token = joinTokenOf(pasted);
|
||||
if (token == null) {
|
||||
return;
|
||||
}
|
||||
emit(JoinSpaceState(token: token, busy: true));
|
||||
try {
|
||||
final preview = await _spaces.preview(token);
|
||||
emit(JoinSpaceState(token: token, preview: preview));
|
||||
} on SpaceRefused catch (refused) {
|
||||
emit(JoinSpaceState(problem: refused.reason));
|
||||
} on Object {
|
||||
emit(const JoinSpaceState(unreachable: true));
|
||||
}
|
||||
}
|
||||
|
||||
/// Mails a code to [address], worded in [language], to prove it is the
|
||||
/// one a mailed invitation went to.
|
||||
Future<void> sendCode(String address, {required String language}) =>
|
||||
_step((state) async {
|
||||
final request = await _spaces.sendJoinCode(
|
||||
address.trim(),
|
||||
language: language,
|
||||
);
|
||||
return state.copyWith(request: request, address: address.trim());
|
||||
});
|
||||
|
||||
/// Joins: by the space's link, or by a mailed invitation with [code],
|
||||
/// mailed for the request [sendCode] made.
|
||||
Future<void> join({String? code}) => _step((state) async {
|
||||
final token = state.token!;
|
||||
if (state.preview!.byEmail) {
|
||||
await _spaces.acceptEmailInvitation(
|
||||
token,
|
||||
request: state.request!,
|
||||
code: code!.trim(),
|
||||
);
|
||||
} else {
|
||||
await _spaces.joinByLink(token);
|
||||
}
|
||||
_requestSync();
|
||||
return state.copyWith(joined: true);
|
||||
});
|
||||
|
||||
Future<void> _step(
|
||||
Future<JoinSpaceState> Function(JoinSpaceState state) step,
|
||||
) async {
|
||||
final before = state.copyWith(unreachable: false);
|
||||
emit(before.copyWith(busy: true));
|
||||
try {
|
||||
emit((await step(before)).copyWith(busy: false));
|
||||
} on SpaceRefused catch (refused) {
|
||||
emit(before.copyWith(problem: refused.reason));
|
||||
} on Object {
|
||||
emit(before.copyWith(unreachable: true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where joining by a link is: the [token] looked up, what it [preview]s,
|
||||
/// for a mailed invitation the [address] a code went to and its
|
||||
/// [request], whether [joined]; [busy] while the instance is asked, and
|
||||
/// what it refused last ([problem]) or that it was out of reach.
|
||||
final class const JoinSpaceState({
|
||||
final String? token,
|
||||
final SpaceJoinPreview? preview,
|
||||
final String? address,
|
||||
final String? request,
|
||||
final bool joined = false,
|
||||
final bool busy = false,
|
||||
final SpaceRefusal? problem,
|
||||
final bool unreachable = false,
|
||||
}) with Equatable {
|
||||
/// Whether a link was looked up and holds.
|
||||
bool get found => preview != null;
|
||||
|
||||
JoinSpaceState copyWith({
|
||||
String? address,
|
||||
String? request,
|
||||
bool? joined,
|
||||
bool? busy,
|
||||
SpaceRefusal? problem,
|
||||
bool? unreachable,
|
||||
}) => JoinSpaceState(
|
||||
token: token,
|
||||
preview: preview,
|
||||
address: address ?? this.address,
|
||||
request: request ?? this.request,
|
||||
joined: joined ?? this.joined,
|
||||
busy: busy ?? this.busy,
|
||||
problem: problem,
|
||||
unreachable: unreachable ?? this.unreachable,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
token,
|
||||
preview,
|
||||
address,
|
||||
request,
|
||||
joined,
|
||||
busy,
|
||||
problem,
|
||||
unreachable,
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
|
||||
/// The shared spaces new to the person (Halkyon's ADR 0044, D40): someone
|
||||
/// else made them a member, and they haven't seen it since. The Inbox lists
|
||||
/// them, the Profile tab's badge counts them, and their studysets' library
|
||||
/// cards say New. Read as the person's data opens and after every sync that
|
||||
/// merged something, as a share brings its space's rows; an instance out of
|
||||
/// reach keeps what was read last.
|
||||
class NewSharesCubit(
|
||||
final SharedSpaceGateway _spaces,
|
||||
|
||||
/// What a sync merged, after which the spaces are read again.
|
||||
final Stream<void> _merges,
|
||||
) extends Cubit<List<SpaceInfo>> {
|
||||
this : super(const []) {
|
||||
_merged = _merges.listen((_) => unawaited(refresh()));
|
||||
}
|
||||
|
||||
late final StreamSubscription<void> _merged;
|
||||
|
||||
/// Whether [space] is new to the person; false for none.
|
||||
bool isNew(String? space) =>
|
||||
space != null && state.any((shared) => shared.id == space);
|
||||
|
||||
/// Reads the spaces new to the person again.
|
||||
Future<void> refresh() async {
|
||||
try {
|
||||
final fresh = [
|
||||
for (final space in await _spaces.mine())
|
||||
if (space.isNew) space,
|
||||
];
|
||||
if (!isClosed) {
|
||||
emit(fresh);
|
||||
}
|
||||
} on Object {
|
||||
// Out of reach: what was read last stays.
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks [space] seen, as the person opened it; nothing for one not new.
|
||||
Future<void> seen(String space) async {
|
||||
if (!isNew(space)) {
|
||||
return;
|
||||
}
|
||||
emit([
|
||||
for (final shared in state)
|
||||
if (shared.id != space) shared,
|
||||
]);
|
||||
try {
|
||||
await _spaces.markSeen(space);
|
||||
} on Object {
|
||||
// Out of reach: it is new again at the next reading, and seen then.
|
||||
}
|
||||
}
|
||||
|
||||
/// Leaves [space], which someone shared with the person; whether it went.
|
||||
Future<bool> leave(String space) async {
|
||||
try {
|
||||
await _spaces.leave(space);
|
||||
} on Object {
|
||||
return false;
|
||||
}
|
||||
await refresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
// Unawaited: a cancel future may never complete under a test's fake
|
||||
// clock (AGENTS.md, Testing).
|
||||
unawaited(_merged.cancel());
|
||||
await super.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -44,8 +44,19 @@ class SpaceMembersCubit(
|
|||
// Without the keys here (no password on this device), the members
|
||||
// show without codes.
|
||||
}
|
||||
String? link;
|
||||
if (members.any((m) => m.you && m.role == SpaceRole.owner)) {
|
||||
try {
|
||||
link = await _spaces.joinLink(space);
|
||||
} on Object {
|
||||
// The members show without the link, which the next reading
|
||||
// tries again.
|
||||
}
|
||||
}
|
||||
if (!isClosed) {
|
||||
emit(SpaceMembersStateReady(members: members, trust: trust));
|
||||
emit(
|
||||
SpaceMembersStateReady(members: members, trust: trust, link: link),
|
||||
);
|
||||
}
|
||||
} on Object {
|
||||
if (!isClosed) {
|
||||
|
|
@ -58,6 +69,20 @@ class SpaceMembersCubit(
|
|||
Future<SpaceAction> invite(String handle, SpaceRole role) =>
|
||||
_run(() => _spaces.invite(space, handle.trim(), role));
|
||||
|
||||
/// Mails an invitation as [role] to [address], worded in [language]
|
||||
/// (Halkyon's ADR 0044, D37).
|
||||
Future<SpaceAction> inviteByEmail(
|
||||
String address,
|
||||
SpaceRole role,
|
||||
String language,
|
||||
) => _run(
|
||||
() => _spaces.inviteByEmail(space, address.trim(), role, language),
|
||||
);
|
||||
|
||||
/// Switches the space's link on, a new one, or off for good (D39).
|
||||
Future<SpaceAction> setJoinLink({required bool on}) =>
|
||||
_run(() => _spaces.setJoinLink(space, on: on));
|
||||
|
||||
/// Gives [member] the role [role].
|
||||
Future<SpaceAction> setRole(SpaceMember member, SpaceRole role) =>
|
||||
_run(() => _spaces.setRole(space, member.id, role));
|
||||
|
|
@ -120,10 +145,12 @@ sealed class const SpaceMembersState() with Equatable {
|
|||
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.
|
||||
/// those with a handle, by their handle in lower case; for the Owner, the
|
||||
/// space's [link] while someone may join by it.
|
||||
final class const SpaceMembersStateReady({
|
||||
required final List<SpaceMember> members,
|
||||
required final Map<String, MemberTrust> trust,
|
||||
final String? link,
|
||||
}) extends SpaceMembersState {
|
||||
/// The person's own role there.
|
||||
SpaceRole? get yourRole =>
|
||||
|
|
@ -134,7 +161,7 @@ final class const SpaceMembersStateReady({
|
|||
member.handle == null ? null : trust[member.handle!.toLowerCase()];
|
||||
|
||||
@override
|
||||
List<Object?> get props => [members, trust];
|
||||
List<Object?> get props => [members, trust, link];
|
||||
}
|
||||
|
||||
/// Not read: no connection, most likely.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,263 @@
|
|||
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/core/dependencies/person_dependencies.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/sharing/bloc/join_space_cubit.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Opens the Library's Join with a link (Halkyon's ADR 0044, D39), the
|
||||
/// link of [token] looked up already where given (a link the app opened).
|
||||
Future<void> showJoinSpace(BuildContext context, {String? token}) =>
|
||||
showAdaptiveSheet<void>(
|
||||
context,
|
||||
builder: (sheet) => SheetScaffold(
|
||||
title: AppLocalizations.of(sheet).libraryJoin,
|
||||
body: JoinSpace(token: token),
|
||||
),
|
||||
);
|
||||
|
||||
/// A link to join, opened in the app (D39): Join with a link on a screen
|
||||
/// of its own, the link looked up.
|
||||
class const JoinScreen({required final String token, super.key})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: Text(AppLocalizations.of(context).libraryJoin)),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth),
|
||||
child: JoinSpace(token: token),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Joining a shared studyset by a link, as the [JoinSpaceCubit] it makes
|
||||
/// goes: the link pasted (or [token], looked up at once), who invites and
|
||||
/// as whom, for a mailed invitation the address and the code mailed there
|
||||
/// (D37), then that it is joined.
|
||||
class const JoinSpace({final String? token, super.key})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => BlocProvider(
|
||||
create: (context) {
|
||||
final person = PersonDependencies.of(context);
|
||||
final cubit = JoinSpaceCubit(person.sharedSpaces, person.sync.syncNow);
|
||||
if (token case final token?) {
|
||||
cubit.look(token);
|
||||
}
|
||||
return cubit;
|
||||
},
|
||||
child: const _JoinSpaceBody(),
|
||||
);
|
||||
}
|
||||
|
||||
class const _JoinSpaceBody() extends StatefulWidget {
|
||||
@override
|
||||
State<_JoinSpaceBody> createState() => _JoinSpaceBodyState();
|
||||
}
|
||||
|
||||
class _JoinSpaceBodyState() extends State<_JoinSpaceBody> {
|
||||
final _link = TextEditingController();
|
||||
final _address = TextEditingController();
|
||||
final _code = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_link.dispose();
|
||||
_address.dispose();
|
||||
_code.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final language = Localizations.localeOf(context).languageCode;
|
||||
return BlocBuilder<JoinSpaceCubit, JoinSpaceState>(
|
||||
builder: (context, state) {
|
||||
final problem = _problemText(l10n, state);
|
||||
final preview = state.preview;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (preview == null) ...[
|
||||
TextField(
|
||||
controller: _link,
|
||||
autofocus: true,
|
||||
autocorrect: false,
|
||||
maxLength: TextLimits.address,
|
||||
buildCounter: nearLimitCounter,
|
||||
decoration: InputDecoration(labelText: l10n.joinLinkLabel),
|
||||
onSubmitted: (text) =>
|
||||
context.read<JoinSpaceCubit>().look(text),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: ListenableBuilder(
|
||||
listenable: _link,
|
||||
builder: (context, _) => FilledButton(
|
||||
onPressed: state.busy || joinTokenOf(_link.text) == null
|
||||
? null
|
||||
: () => context.read<JoinSpaceCubit>().look(_link.text),
|
||||
child: state.busy
|
||||
? const ButtonLoadingIndicator()
|
||||
: Text(l10n.joinContinue),
|
||||
),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
Text(
|
||||
l10n.joinInvites(_inviterOf(l10n, preview)),
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
preview.role == SpaceRole.editor
|
||||
? l10n.joinAsEditor
|
||||
: l10n.joinAsViewer,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (state.joined)
|
||||
Text(l10n.joinDone)
|
||||
else if (preview.member)
|
||||
Text(l10n.joinAlreadyMember)
|
||||
else if (!preview.byEmail)
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: FilledButton(
|
||||
onPressed: state.busy
|
||||
? null
|
||||
: () => context.read<JoinSpaceCubit>().join(),
|
||||
child: state.busy
|
||||
? const ButtonLoadingIndicator()
|
||||
: Text(l10n.joinAction),
|
||||
),
|
||||
)
|
||||
else if (state.request == null) ...[
|
||||
Text(l10n.joinEmailHint),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _address,
|
||||
autocorrect: false,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
maxLength: TextLimits.emailAddress,
|
||||
buildCounter: nearLimitCounter,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.shareInviteAddress,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: ListenableBuilder(
|
||||
listenable: _address,
|
||||
builder: (context, _) => FilledButton(
|
||||
onPressed: state.busy || _address.text.trim().isEmpty
|
||||
? null
|
||||
: () => context.read<JoinSpaceCubit>().sendCode(
|
||||
_address.text,
|
||||
language: language,
|
||||
),
|
||||
child: state.busy
|
||||
? const ButtonLoadingIndicator()
|
||||
: Text(l10n.joinSendCode),
|
||||
),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
Text(l10n.joinCodeSent(state.address ?? '')),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _code,
|
||||
autofocus: true,
|
||||
autocorrect: false,
|
||||
maxLength: TextLimits.name,
|
||||
buildCounter: nearLimitCounter,
|
||||
decoration: InputDecoration(labelText: l10n.joinCodeLabel),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: state.busy
|
||||
? null
|
||||
: () => context.read<JoinSpaceCubit>().sendCode(
|
||||
state.address ?? '',
|
||||
language: language,
|
||||
),
|
||||
child: Text(l10n.joinSendCode),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ListenableBuilder(
|
||||
listenable: _code,
|
||||
builder: (context, _) => FilledButton(
|
||||
onPressed: state.busy || _code.text.trim().isEmpty
|
||||
? null
|
||||
: () => context.read<JoinSpaceCubit>().join(
|
||||
code: _code.text,
|
||||
),
|
||||
child: state.busy
|
||||
? const ButtonLoadingIndicator()
|
||||
: Text(l10n.joinAction),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
if (problem != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
problem,
|
||||
style: TextStyle(color: theme.colorScheme.error),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Who invites, as the preview names them.
|
||||
String _inviterOf(AppLocalizations l10n, SpaceJoinPreview preview) =>
|
||||
switch ((preview.inviterName, preview.inviterHandle)) {
|
||||
('', final handle?) => '@$handle',
|
||||
(final name, final handle?) => '$name (@$handle)',
|
||||
(final name, null) when name.isNotEmpty => name,
|
||||
_ => l10n.joinSomeone,
|
||||
};
|
||||
|
||||
/// What went wrong last, in words; null for nothing.
|
||||
String? _problemText(AppLocalizations l10n, JoinSpaceState state) {
|
||||
if (state.unreachable) {
|
||||
return l10n.shareMembersUnreachable;
|
||||
}
|
||||
return switch (state.problem) {
|
||||
null => null,
|
||||
SpaceRefusal.notFound => l10n.joinNotFound,
|
||||
SpaceRefusal.wrongCode => l10n.joinWrongCode,
|
||||
SpaceRefusal.codeExpired => l10n.joinCodeExpired,
|
||||
SpaceRefusal.otherAddress => l10n.joinOtherAddress,
|
||||
SpaceRefusal.notAnAddress => l10n.shareRefusedNotAnAddress,
|
||||
SpaceRefusal.tooManyMails => l10n.shareRefusedTooManyMails,
|
||||
SpaceRefusal.mailFailed => l10n.shareRefusedMailFailed,
|
||||
SpaceRefusal.unavailable => l10n.shareRefusedUnavailable,
|
||||
SpaceRefusal.notAllowed ||
|
||||
SpaceRefusal.noSuchHandle ||
|
||||
SpaceRefusal.alreadyMember ||
|
||||
SpaceRefusal.ownerRole ||
|
||||
SpaceRefusal.self ||
|
||||
SpaceRefusal.nextOwnerNeeded ||
|
||||
SpaceRefusal.lastMember ||
|
||||
SpaceRefusal.handleNeeded => l10n.shareRefusedNotAllowed,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/sharing/bloc/new_shares_cubit.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Marks the shared [space] seen as [child] first shows, a screen of a
|
||||
/// studyset in it opened (Halkyon's ADR 0044, D40): no longer New in the
|
||||
/// library, nor listed in the Inbox. Nothing for a studyset not shared, or
|
||||
/// one not new.
|
||||
class const MarksShareSeen({
|
||||
required final String? space,
|
||||
required final Widget child,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<MarksShareSeen> createState() => _MarksShareSeenState();
|
||||
}
|
||||
|
||||
class _MarksShareSeenState() extends State<MarksShareSeen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.space case final space?) {
|
||||
// After the first frame: the cubit emits, which rebuilds the library
|
||||
// beneath, never while this builds.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
context.read<NewSharesCubit?>()?.seen(space);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.child;
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:halkyon_app/halkyon_app.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/new_shares_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The Inbox's studysets shared with the person that are new to them
|
||||
/// (Halkyon's ADR 0044, D40), from the [NewSharesCubit] above: "@alice
|
||||
/// shared Japanese with you", each opened (which marks it seen) or left;
|
||||
/// one whose rows haven't arrived yet says it is on its way. Nothing while
|
||||
/// none is new.
|
||||
class const SharedWithYou({super.key}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shares = context.watch<NewSharesCubit?>()?.state ?? const [];
|
||||
if (shares.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return StreamBuilder<List<Studyset>>(
|
||||
stream: PersonDependencies.of(context).studysets.watchAll(),
|
||||
builder: (context, snapshot) {
|
||||
final all = snapshot.data ?? const <Studyset>[];
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
l10n.inboxShared,
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
for (final share in shares)
|
||||
_ShareRow(share: share, top: _topOf(share.id, all)),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The studyset at the top of [space] among [all]: the one whose parent
|
||||
/// isn't in it; null while none has arrived.
|
||||
Studyset? _topOf(String space, List<Studyset> all) {
|
||||
final inSpace = {
|
||||
for (final studyset in all)
|
||||
if (studyset.sharedIn == space) studyset.id,
|
||||
};
|
||||
return all
|
||||
.where(
|
||||
(studyset) =>
|
||||
studyset.sharedIn == space && !inSpace.contains(studyset.parentId),
|
||||
)
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
class const _ShareRow({
|
||||
required final SpaceInfo share,
|
||||
required final Studyset? top,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final title = top?.content.title ?? l10n.inboxSharedArriving;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
switch (share.sharedBy) {
|
||||
final handle? => l10n.inboxSharedBy(handle, title),
|
||||
null => l10n.inboxSharedNoHandle(title),
|
||||
},
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: switch (top) {
|
||||
final top? => () {
|
||||
context.read<NewSharesCubit>().seen(share.id);
|
||||
GoRouter.of(context).go(RoutePaths.studyset(top.id));
|
||||
},
|
||||
null => null,
|
||||
},
|
||||
child: Text(l10n.inboxSharedOpen),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _leave(context, title),
|
||||
child: Text(l10n.shareLeave),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _leave(BuildContext context, String title) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final shares = context.read<NewSharesCubit>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final sync = PersonDependencies.of(context).sync;
|
||||
final sure = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(l10n.shareLeaveTitle(title)),
|
||||
content: Text(l10n.shareLeaveBody),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(l10n.shareLeave),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (sure != true) {
|
||||
return;
|
||||
}
|
||||
if (await shares.leave(share.id)) {
|
||||
// It goes from the library with the next sync, which starts now.
|
||||
sync.syncNow();
|
||||
} else {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.shareMembersUnreachable)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
|
|
@ -12,8 +13,9 @@ 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.
|
||||
/// tap away from their safety code; inviting by @handle or by mail (D37)
|
||||
/// and the space's link (D39) 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
|
||||
|
|
@ -64,6 +66,7 @@ class const _Ready({
|
|||
trust: state.trustOf(member),
|
||||
manage: owner && !member.you,
|
||||
),
|
||||
if (owner) _JoinLink(link: state.link),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
|
|
@ -98,18 +101,41 @@ class const _Ready({
|
|||
|
||||
Future<void> _invite(BuildContext context) async {
|
||||
final cubit = context.read<SpaceMembersCubit>();
|
||||
final asked = await showDialog<({String handle, SpaceRole role})>(
|
||||
final language = Localizations.localeOf(context).languageCode;
|
||||
final asked = await showDialog<_Invitation>(
|
||||
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 (asked == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
if (asked.byEmail) {
|
||||
final outcome = await cubit.inviteByEmail(
|
||||
asked.who,
|
||||
asked.role,
|
||||
language,
|
||||
);
|
||||
if (context.mounted) {
|
||||
_report(context, outcome);
|
||||
if (outcome is SpaceActionDone) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
AppLocalizations.of(context).shareInviteMailed(asked.who),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
_report(context, outcome);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
final handle = asked.who.startsWith('@')
|
||||
? asked.who.substring(1)
|
||||
: asked.who;
|
||||
final outcome = await cubit.invite(handle, asked.role);
|
||||
if (context.mounted) {
|
||||
_report(context, outcome);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -205,11 +231,13 @@ class const _MemberRow({
|
|||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: CircleAvatar(
|
||||
child: Text(
|
||||
_nameOf(member).characters.firstOrNull?.toUpperCase() ?? '',
|
||||
),
|
||||
child: member.byEmail
|
||||
? const Icon(LucideIcons.mail, size: 18)
|
||||
: Text(_nameOf(member).characters.firstOrNull?.toUpperCase() ?? ''),
|
||||
),
|
||||
title: Text(
|
||||
member.byEmail ? l10n.shareInvitedByEmail : _nameOf(member),
|
||||
),
|
||||
title: Text(_nameOf(member)),
|
||||
subtitle: Text(
|
||||
[role, ?note].join(' · '),
|
||||
style: trust?.changed ?? false
|
||||
|
|
@ -316,7 +344,63 @@ class const _RoleMenu({required final SpaceMember member})
|
|||
}
|
||||
}
|
||||
|
||||
/// Asks for a handle and a role to invite with, an Editor at first.
|
||||
/// Whom to invite and as what: a handle, or an address mailed (`byEmail`).
|
||||
typedef _Invitation = ({String who, SpaceRole role, bool byEmail});
|
||||
|
||||
/// The space's link (D39), for its Owner: a switch letting anyone with it
|
||||
/// join as a Viewer, and while on, the link with a button copying it.
|
||||
class const _JoinLink({required final String? link}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.shareLinkTitle),
|
||||
subtitle: Text(l10n.shareLinkHint),
|
||||
value: link != null,
|
||||
onChanged: (on) async {
|
||||
final outcome = await context.read<SpaceMembersCubit>().setJoinLink(
|
||||
on: on,
|
||||
);
|
||||
if (context.mounted) {
|
||||
_report(context, outcome);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (link case final link?)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SelectableText(
|
||||
link,
|
||||
maxLines: 1,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: link));
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.shareLinkCopied)),
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: const Icon(LucideIcons.copy, size: 18),
|
||||
label: Text(l10n.shareLinkCopy),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks whom to invite, by handle or by address, and as what role, an
|
||||
/// Editor at first.
|
||||
class const _InviteDialog() extends StatefulWidget {
|
||||
@override
|
||||
State<_InviteDialog> createState() => _InviteDialogState();
|
||||
|
|
@ -325,6 +409,7 @@ class const _InviteDialog() extends StatefulWidget {
|
|||
class _InviteDialogState() extends State<_InviteDialog> {
|
||||
final _handle = TextEditingController();
|
||||
var _role = SpaceRole.editor;
|
||||
var _byEmail = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
|
|
@ -334,7 +419,9 @@ class _InviteDialogState() extends State<_InviteDialog> {
|
|||
|
||||
void _submit() {
|
||||
if (_handle.text.trim().isNotEmpty) {
|
||||
Navigator.of(context).pop((handle: _handle.text.trim(), role: _role));
|
||||
Navigator.of(
|
||||
context,
|
||||
).pop((who: _handle.text.trim(), role: _role, byEmail: _byEmail));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -347,16 +434,41 @@ class _InviteDialogState() extends State<_InviteDialog> {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SegmentedButton<bool>(
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: false,
|
||||
label: Text(l10n.shareInviteByHandle),
|
||||
),
|
||||
ButtonSegment(value: true, label: Text(l10n.shareInviteByEmail)),
|
||||
],
|
||||
selected: {_byEmail},
|
||||
onSelectionChanged: (choice) =>
|
||||
setState(() => _byEmail = choice.single),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _handle,
|
||||
autofocus: true,
|
||||
autocorrect: false,
|
||||
keyboardType: _byEmail
|
||||
? TextInputType.emailAddress
|
||||
: TextInputType.text,
|
||||
maxLength: _byEmail ? TextLimits.emailAddress : TextLimits.name,
|
||||
buildCounter: nearLimitCounter,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.shareInviteHandle,
|
||||
prefixText: '@',
|
||||
labelText: _byEmail
|
||||
? l10n.shareInviteAddress
|
||||
: l10n.shareInviteHandle,
|
||||
prefixText: _byEmail ? null : '@',
|
||||
),
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
if (_byEmail)
|
||||
Text(
|
||||
l10n.shareInviteEmailHint,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SegmentedButton<SpaceRole>(
|
||||
segments: [
|
||||
|
|
@ -456,12 +568,17 @@ void _report(BuildContext context, SpaceAction outcome) {
|
|||
SpaceRefusal.alreadyMember => l10n.shareRefusedAlreadyMember,
|
||||
SpaceRefusal.lastMember => l10n.shareRefusedLastMember,
|
||||
SpaceRefusal.unavailable => l10n.shareRefusedUnavailable,
|
||||
SpaceRefusal.notAnAddress => l10n.shareRefusedNotAnAddress,
|
||||
SpaceRefusal.tooManyMails => l10n.shareRefusedTooManyMails,
|
||||
SpaceRefusal.mailFailed => l10n.shareRefusedMailFailed,
|
||||
SpaceRefusal.handleNeeded => l10n.shareRefusedHandleNeeded,
|
||||
SpaceRefusal.notAllowed ||
|
||||
SpaceRefusal.ownerRole ||
|
||||
SpaceRefusal.self ||
|
||||
SpaceRefusal.nextOwnerNeeded ||
|
||||
SpaceRefusal.handleNeeded ||
|
||||
SpaceRefusal.otherAddress ||
|
||||
SpaceRefusal.wrongCode ||
|
||||
SpaceRefusal.codeExpired ||
|
||||
SpaceRefusal.notFound => l10n.shareRefusedNotAllowed,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topi
|
|||
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/assistant_field.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/publish/bloc/published_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/publish/widget/share_button.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/sharing/widget/join_space.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/start_studying.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
|
||||
|
|
@ -230,6 +231,7 @@ class _LibraryState() extends State<_Library> {
|
|||
tooltip: l10n.libraryOptions,
|
||||
onSelected: (option) => switch (option) {
|
||||
_LibraryOption.import => unawaited(_import(context)),
|
||||
_LibraryOption.join => unawaited(showJoinSpace(context)),
|
||||
_LibraryOption.refresh => _refresh(context),
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
|
|
@ -239,6 +241,11 @@ class _LibraryState() extends State<_Library> {
|
|||
LucideIcons.fileInput,
|
||||
l10n.libraryImport,
|
||||
),
|
||||
(
|
||||
_LibraryOption.join,
|
||||
LucideIcons.link,
|
||||
l10n.libraryJoin,
|
||||
),
|
||||
(
|
||||
_LibraryOption.refresh,
|
||||
LucideIcons.refreshCw,
|
||||
|
|
@ -1056,6 +1063,7 @@ class const _SearchRow({
|
|||
/// The library's options, behind its menu button.
|
||||
enum _LibraryOption() {
|
||||
import,
|
||||
join,
|
||||
refresh,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/l10n/content_languages.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/theme/learn_colors.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/content_counts_line.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/publish/widget/share_button.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/sharing/bloc/new_shares_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/subject.dart';
|
||||
|
|
@ -29,7 +31,9 @@ import 'package:material_ui/material_ui.dart';
|
|||
///
|
||||
/// Who made it is the person ("By you") or, in a copy of a published
|
||||
/// studyset, its author by the handle its location names. Whether it is
|
||||
/// public is a lock or a globe, which opens the Publish sheet.
|
||||
/// public is a lock or a globe, which opens the Publish sheet. One someone
|
||||
/// shared with the person says New until they open it (Halkyon's ADR 0044,
|
||||
/// D40).
|
||||
class const StudysetCard({
|
||||
/// The studyset.
|
||||
required final Studyset studyset,
|
||||
|
|
@ -143,6 +147,9 @@ class _StudysetCardState() extends State<StudysetCard> {
|
|||
),
|
||||
const Divider(height: 1, thickness: 1),
|
||||
_BottomBar(
|
||||
isNew: context.select<NewSharesCubit?, bool>(
|
||||
(shares) => shares?.isNew(studyset.sharedIn) ?? false,
|
||||
),
|
||||
by: _by(AppLocalizations.of(context), studyset),
|
||||
published: widget.published,
|
||||
onShare: widget.onShare,
|
||||
|
|
@ -355,6 +362,7 @@ class const _Paused() extends StatelessWidget {
|
|||
|
||||
/// Who made it, the star, whether it is public, and the options.
|
||||
class const _BottomBar({
|
||||
required final bool isNew,
|
||||
required final String by,
|
||||
required final bool published,
|
||||
required final VoidCallback onShare,
|
||||
|
|
@ -374,7 +382,21 @@ class const _BottomBar({
|
|||
Expanded(
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: _By(by),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isNew)
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(start: 12),
|
||||
child: Badge(
|
||||
label: Text(l10n.studysetNew),
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
textColor: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
Flexible(child: _By(by)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Its place is kept while hidden, so revealing it doesn't move the
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ void main() {
|
|||
String location, {
|
||||
AuthGateway? authGateway,
|
||||
SignInAddresses? signInAddresses,
|
||||
SharedSpaceGateway? sharedSpaces,
|
||||
}) async {
|
||||
tester.view.physicalSize = Size(width, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
|
|
@ -144,6 +145,7 @@ void main() {
|
|||
tagGateway: tagGateway,
|
||||
settingsGateway: settingsGateway,
|
||||
discussionGateway: discussions,
|
||||
sharedSpaces: sharedSpaces,
|
||||
),
|
||||
location: location,
|
||||
);
|
||||
|
|
@ -231,6 +233,27 @@ void main() {
|
|||
|
||||
expect(find.textContaining('anna anna'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('on $window, the Inbox with a long studyset someone with a '
|
||||
'long handle shared', (tester) async {
|
||||
final handle = long('owner', 30).replaceAll(' ', '_');
|
||||
final spaces = FakeSharedSpaceServer(FakeSharedKeysServer())
|
||||
..addPerson('owner', handle: handle)
|
||||
..addPerson('person', handle: 'person');
|
||||
final space = await spaces.as('owner').create('studyset');
|
||||
await spaces.as('owner').invite(space, 'person', SpaceRole.viewer);
|
||||
studysets.shared['japanese'] = (space: space, viewOnly: true);
|
||||
await show(
|
||||
tester,
|
||||
RoutePaths.profile,
|
||||
authGateway: FakeAuthGateway(
|
||||
status: const SignedIn(authUserId: 'person', isIdentified: true),
|
||||
),
|
||||
sharedSpaces: spaces.as('person'),
|
||||
);
|
||||
|
||||
expect(find.textContaining('@$handle shared'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue