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:
Tizian 「ティツィアーン」 2026-11-11 19:09:12 +01:00
commit fb6b78fa19
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
18 changed files with 2443 additions and 54 deletions

View file

@ -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"
}

View file

@ -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"}
}

View file

@ -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

View file

@ -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';
}

View file

@ -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`).

View file

@ -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),
),
),
),
),

View file

@ -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(

View file

@ -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,

View file

@ -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,

View file

@ -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,
];
}

View file

@ -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();
}
}

View file

@ -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.

View file

@ -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,
};
}

View file

@ -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;
}

View file

@ -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)),
);
}
}
}

View file

@ -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,
},
};

View file

@ -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,
}

View file

@ -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