feat: public profiles, a handle on the account domain with name, bio, links and picture
Stage 1 of the public tier (ADR 0005). A person with an identity has a public profile: a handle by Mastodon's rules (1 to 30 letters, digits or underscores, unique whatever its case, a few kept for the instance), changeable until they first publish, on the instance's account domain (ACCOUNT_DOMAIN, else the server's host; the instance info names it), so `raisondetredev@halkyon.eu`; a display name, a bio, up to four links and an avatar. The table is server only: the app gets a Profile without the person's id. ProfileEndpoint (identified only) reads, saves and removes the caller's own; ProfilesEndpoint finds anyone's by handle. An avatar is a PNG of at most 1 MB that the app draws anew from the picture chosen, its middle square at 400 pixels, so a photo's metadata never leaves the device. The server keeps it in the public storage named by its content, so its URL tells nothing about whose it is, and deletes it once no profile shows it. Deleting a person deletes their profile and its picture. In the app, the Profile tab holds the profile as a form, laid out as the old app's; anonymous people are told GitHub sign-in gives them one. Migration 20261009184924904-public-profiles creates public_profile with a unique index on the person and one on the handle in lower case; it touches no other table, and the old server never sees it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
b8462d650f
commit
7f6147d689
20 changed files with 16609 additions and 3 deletions
|
|
@ -391,6 +391,44 @@
|
|||
"profileSwitchMessage": "Dieses GitHub-Konto hat bereits Fortschritt von einem anderen Gerät. Die Lernsets auf diesem Gerät lassen sich noch nicht damit zusammenführen: Wenn du wechselst, werden sie gelöscht.",
|
||||
"profileSwitchStay": "Anonym bleiben",
|
||||
"profileSwitchConfirm": "Wechseln",
|
||||
"publicProfileTitle": "Öffentliches Profil",
|
||||
"publicProfileIntro": "Wie andere dich sehen, sobald du veröffentlichst: das Handle, unter dem sie dich finden, dein Name, ein paar Worte und Links. Nichts davon ist sichtbar, bevor du es speicherst.",
|
||||
"publicProfileAnonymousHint": "Melde dich mit GitHub an, um ein öffentliches Profil zu haben und zu veröffentlichen.",
|
||||
"publicProfileDisplayName": "Anzeigename",
|
||||
"publicProfileDisplayNameHint": "Der Name neben deinem Handle",
|
||||
"publicProfileHandle": "Handle",
|
||||
"publicProfileHandleHint": "Buchstaben, Ziffern und Unterstriche, bis zu 30",
|
||||
"publicProfileAddress": "Andere finden dich als @{address}",
|
||||
"@publicProfileAddress": {"placeholders": {"address": {"type": "String"}}},
|
||||
"publicProfileHandleFixed": "Fest, seit du veröffentlicht hast: Andere verweisen darauf.",
|
||||
"publicProfileBio": "Über dich",
|
||||
"publicProfileLinks": "Links",
|
||||
"publicProfileLinksHint": "Deine Website oder andere Konten, bis zu 4.",
|
||||
"publicProfileLinkHint": "https://…",
|
||||
"publicProfileAddLink": "Link hinzufügen",
|
||||
"publicProfileRemoveLink": "Link entfernen",
|
||||
"publicProfileSaved": "Profil gespeichert.",
|
||||
"publicProfileRemove": "Profil entfernen",
|
||||
"publicProfileRemoveTitle": "Dein öffentliches Profil entfernen?",
|
||||
"publicProfileRemoveMessage": "Dein Handle wird für andere frei.",
|
||||
"publicProfileRemoved": "Profil entfernt.",
|
||||
"publicProfileFailed": "Die Instanz ist nicht erreichbar. Versuche es erneut.",
|
||||
"publicProfileUnreachable": "Dein öffentliches Profil ist gerade nicht erreichbar.",
|
||||
"publicProfileRetry": "Erneut versuchen",
|
||||
"profileProblemHandleInvalid": "Ein Handle besteht aus 1 bis 30 Buchstaben (a bis z), Ziffern oder Unterstrichen.",
|
||||
"profileProblemHandleReserved": "Dieses Handle ist der Instanz vorbehalten.",
|
||||
"profileProblemHandleTaken": "Dieses Handle hat schon jemand anderes.",
|
||||
"profileProblemPublished": "Du hast veröffentlicht: Dein Handle kann sich nicht mehr ändern und dein Profil nicht mehr gehen.",
|
||||
"profileProblemDisplayNameTooLong": "Höchstens 30 Zeichen.",
|
||||
"profileProblemBioTooLong": "Höchstens 500 Zeichen.",
|
||||
"profileProblemTooManyLinks": "Höchstens 4 Links.",
|
||||
"profileProblemLinkInvalid": "Links beginnen mit https:// oder http://.",
|
||||
"profileProblemAvatarInvalid": "Dieses Bild lässt sich nicht verwenden: Wähle ein PNG-, JPEG-, WebP- oder GIF-Bild.",
|
||||
"publicProfilePicture": "Bild",
|
||||
"publicProfileChoosePicture": "Bild wählen",
|
||||
"publicProfileRemovePicture": "Bild entfernen",
|
||||
"publicProfilePictureType": "Bilder",
|
||||
"publicProfilePictureLater": "Speichere dein Profil, um ein Bild hinzuzufügen.",
|
||||
|
||||
"settingsTitle": "Einstellungen",
|
||||
"settingsAccount": "Konto",
|
||||
|
|
|
|||
|
|
@ -423,6 +423,44 @@
|
|||
"profileSwitchMessage": "This GitHub account already has progress from another device. The studysets on this device can't be combined with it yet: if you switch, they are deleted.",
|
||||
"profileSwitchStay": "Stay anonymous",
|
||||
"profileSwitchConfirm": "Switch",
|
||||
"publicProfileTitle": "Public profile",
|
||||
"publicProfileIntro": "How others see you once you publish: the handle they find you by, your name, a few words and links. Nothing here shows until you save it.",
|
||||
"publicProfileAnonymousHint": "Sign in with GitHub to have a public profile and publish.",
|
||||
"publicProfileDisplayName": "Display name",
|
||||
"publicProfileDisplayNameHint": "The name shown beside your handle",
|
||||
"publicProfileHandle": "Handle",
|
||||
"publicProfileHandleHint": "Letters, digits and underscores, up to 30",
|
||||
"publicProfileAddress": "Others find you as @{address}",
|
||||
"@publicProfileAddress": {"placeholders": {"address": {"type": "String"}}},
|
||||
"publicProfileHandleFixed": "Fixed since you published: others refer to it.",
|
||||
"publicProfileBio": "Bio",
|
||||
"publicProfileLinks": "Links",
|
||||
"publicProfileLinksHint": "Your site or other accounts, up to 4.",
|
||||
"publicProfileLinkHint": "https://…",
|
||||
"publicProfileAddLink": "Add link",
|
||||
"publicProfileRemoveLink": "Remove link",
|
||||
"publicProfileSaved": "Profile saved.",
|
||||
"publicProfileRemove": "Remove profile",
|
||||
"publicProfileRemoveTitle": "Remove your public profile?",
|
||||
"publicProfileRemoveMessage": "Your handle becomes free for others to take.",
|
||||
"publicProfileRemoved": "Profile removed.",
|
||||
"publicProfileFailed": "Couldn't reach the instance. Try again.",
|
||||
"publicProfileUnreachable": "Your public profile can't be reached right now.",
|
||||
"publicProfileRetry": "Retry",
|
||||
"profileProblemHandleInvalid": "A handle is 1 to 30 letters (a to z), digits or underscores.",
|
||||
"profileProblemHandleReserved": "This handle is kept for the instance.",
|
||||
"profileProblemHandleTaken": "Someone else has this handle.",
|
||||
"profileProblemPublished": "You have published: your handle can't change, nor your profile go.",
|
||||
"profileProblemDisplayNameTooLong": "At most 30 characters.",
|
||||
"profileProblemBioTooLong": "At most 500 characters.",
|
||||
"profileProblemTooManyLinks": "At most 4 links.",
|
||||
"profileProblemLinkInvalid": "Links start with https:// or http://.",
|
||||
"profileProblemAvatarInvalid": "This picture can't be used: choose a PNG, JPEG, WebP or GIF image.",
|
||||
"publicProfilePicture": "Picture",
|
||||
"publicProfileChoosePicture": "Choose picture",
|
||||
"publicProfileRemovePicture": "Remove picture",
|
||||
"publicProfilePictureType": "Images",
|
||||
"publicProfilePictureLater": "Save your profile to add a picture.",
|
||||
|
||||
"settingsTitle": "Settings",
|
||||
"settingsAccount": "Account",
|
||||
|
|
|
|||
|
|
@ -2380,6 +2380,228 @@ abstract class AppLocalizations {
|
|||
/// **'Switch'**
|
||||
String get profileSwitchConfirm;
|
||||
|
||||
/// No description provided for @publicProfileTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Public profile'**
|
||||
String get publicProfileTitle;
|
||||
|
||||
/// No description provided for @publicProfileIntro.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'How others see you once you publish: the handle they find you by, your name, a few words and links. Nothing here shows until you save it.'**
|
||||
String get publicProfileIntro;
|
||||
|
||||
/// No description provided for @publicProfileAnonymousHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sign in with GitHub to have a public profile and publish.'**
|
||||
String get publicProfileAnonymousHint;
|
||||
|
||||
/// No description provided for @publicProfileDisplayName.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Display name'**
|
||||
String get publicProfileDisplayName;
|
||||
|
||||
/// No description provided for @publicProfileDisplayNameHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The name shown beside your handle'**
|
||||
String get publicProfileDisplayNameHint;
|
||||
|
||||
/// No description provided for @publicProfileHandle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Handle'**
|
||||
String get publicProfileHandle;
|
||||
|
||||
/// No description provided for @publicProfileHandleHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Letters, digits and underscores, up to 30'**
|
||||
String get publicProfileHandleHint;
|
||||
|
||||
/// No description provided for @publicProfileAddress.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Others find you as @{address}'**
|
||||
String publicProfileAddress(String address);
|
||||
|
||||
/// No description provided for @publicProfileHandleFixed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Fixed since you published: others refer to it.'**
|
||||
String get publicProfileHandleFixed;
|
||||
|
||||
/// No description provided for @publicProfileBio.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Bio'**
|
||||
String get publicProfileBio;
|
||||
|
||||
/// No description provided for @publicProfileLinks.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Links'**
|
||||
String get publicProfileLinks;
|
||||
|
||||
/// No description provided for @publicProfileLinksHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your site or other accounts, up to 4.'**
|
||||
String get publicProfileLinksHint;
|
||||
|
||||
/// No description provided for @publicProfileLinkHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'https://…'**
|
||||
String get publicProfileLinkHint;
|
||||
|
||||
/// No description provided for @publicProfileAddLink.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add link'**
|
||||
String get publicProfileAddLink;
|
||||
|
||||
/// No description provided for @publicProfileRemoveLink.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove link'**
|
||||
String get publicProfileRemoveLink;
|
||||
|
||||
/// No description provided for @publicProfileSaved.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Profile saved.'**
|
||||
String get publicProfileSaved;
|
||||
|
||||
/// No description provided for @publicProfileRemove.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove profile'**
|
||||
String get publicProfileRemove;
|
||||
|
||||
/// No description provided for @publicProfileRemoveTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove your public profile?'**
|
||||
String get publicProfileRemoveTitle;
|
||||
|
||||
/// No description provided for @publicProfileRemoveMessage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your handle becomes free for others to take.'**
|
||||
String get publicProfileRemoveMessage;
|
||||
|
||||
/// No description provided for @publicProfileRemoved.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Profile removed.'**
|
||||
String get publicProfileRemoved;
|
||||
|
||||
/// No description provided for @publicProfileFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Couldn\'t reach the instance. Try again.'**
|
||||
String get publicProfileFailed;
|
||||
|
||||
/// No description provided for @publicProfileUnreachable.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your public profile can\'t be reached right now.'**
|
||||
String get publicProfileUnreachable;
|
||||
|
||||
/// No description provided for @publicProfileRetry.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Retry'**
|
||||
String get publicProfileRetry;
|
||||
|
||||
/// No description provided for @profileProblemHandleInvalid.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'A handle is 1 to 30 letters (a to z), digits or underscores.'**
|
||||
String get profileProblemHandleInvalid;
|
||||
|
||||
/// No description provided for @profileProblemHandleReserved.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This handle is kept for the instance.'**
|
||||
String get profileProblemHandleReserved;
|
||||
|
||||
/// No description provided for @profileProblemHandleTaken.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Someone else has this handle.'**
|
||||
String get profileProblemHandleTaken;
|
||||
|
||||
/// No description provided for @profileProblemPublished.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'You have published: your handle can\'t change, nor your profile go.'**
|
||||
String get profileProblemPublished;
|
||||
|
||||
/// No description provided for @profileProblemDisplayNameTooLong.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'At most 30 characters.'**
|
||||
String get profileProblemDisplayNameTooLong;
|
||||
|
||||
/// No description provided for @profileProblemBioTooLong.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'At most 500 characters.'**
|
||||
String get profileProblemBioTooLong;
|
||||
|
||||
/// No description provided for @profileProblemTooManyLinks.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'At most 4 links.'**
|
||||
String get profileProblemTooManyLinks;
|
||||
|
||||
/// No description provided for @profileProblemLinkInvalid.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Links start with https:// or http://.'**
|
||||
String get profileProblemLinkInvalid;
|
||||
|
||||
/// No description provided for @profileProblemAvatarInvalid.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This picture can\'t be used: choose a PNG, JPEG, WebP or GIF image.'**
|
||||
String get profileProblemAvatarInvalid;
|
||||
|
||||
/// No description provided for @publicProfilePicture.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Picture'**
|
||||
String get publicProfilePicture;
|
||||
|
||||
/// No description provided for @publicProfileChoosePicture.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose picture'**
|
||||
String get publicProfileChoosePicture;
|
||||
|
||||
/// No description provided for @publicProfileRemovePicture.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove picture'**
|
||||
String get publicProfileRemovePicture;
|
||||
|
||||
/// No description provided for @publicProfilePictureType.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Images'**
|
||||
String get publicProfilePictureType;
|
||||
|
||||
/// No description provided for @publicProfilePictureLater.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save your profile to add a picture.'**
|
||||
String get publicProfilePictureLater;
|
||||
|
||||
/// No description provided for @settingsTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -1397,6 +1397,133 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get profileSwitchConfirm => 'Wechseln';
|
||||
|
||||
@override
|
||||
String get publicProfileTitle => 'Öffentliches Profil';
|
||||
|
||||
@override
|
||||
String get publicProfileIntro =>
|
||||
'Wie andere dich sehen, sobald du veröffentlichst: das Handle, unter dem sie dich finden, dein Name, ein paar Worte und Links. Nichts davon ist sichtbar, bevor du es speicherst.';
|
||||
|
||||
@override
|
||||
String get publicProfileAnonymousHint =>
|
||||
'Melde dich mit GitHub an, um ein öffentliches Profil zu haben und zu veröffentlichen.';
|
||||
|
||||
@override
|
||||
String get publicProfileDisplayName => 'Anzeigename';
|
||||
|
||||
@override
|
||||
String get publicProfileDisplayNameHint => 'Der Name neben deinem Handle';
|
||||
|
||||
@override
|
||||
String get publicProfileHandle => 'Handle';
|
||||
|
||||
@override
|
||||
String get publicProfileHandleHint =>
|
||||
'Buchstaben, Ziffern und Unterstriche, bis zu 30';
|
||||
|
||||
@override
|
||||
String publicProfileAddress(String address) {
|
||||
return 'Andere finden dich als @$address';
|
||||
}
|
||||
|
||||
@override
|
||||
String get publicProfileHandleFixed =>
|
||||
'Fest, seit du veröffentlicht hast: Andere verweisen darauf.';
|
||||
|
||||
@override
|
||||
String get publicProfileBio => 'Über dich';
|
||||
|
||||
@override
|
||||
String get publicProfileLinks => 'Links';
|
||||
|
||||
@override
|
||||
String get publicProfileLinksHint =>
|
||||
'Deine Website oder andere Konten, bis zu 4.';
|
||||
|
||||
@override
|
||||
String get publicProfileLinkHint => 'https://…';
|
||||
|
||||
@override
|
||||
String get publicProfileAddLink => 'Link hinzufügen';
|
||||
|
||||
@override
|
||||
String get publicProfileRemoveLink => 'Link entfernen';
|
||||
|
||||
@override
|
||||
String get publicProfileSaved => 'Profil gespeichert.';
|
||||
|
||||
@override
|
||||
String get publicProfileRemove => 'Profil entfernen';
|
||||
|
||||
@override
|
||||
String get publicProfileRemoveTitle => 'Dein öffentliches Profil entfernen?';
|
||||
|
||||
@override
|
||||
String get publicProfileRemoveMessage => 'Dein Handle wird für andere frei.';
|
||||
|
||||
@override
|
||||
String get publicProfileRemoved => 'Profil entfernt.';
|
||||
|
||||
@override
|
||||
String get publicProfileFailed =>
|
||||
'Die Instanz ist nicht erreichbar. Versuche es erneut.';
|
||||
|
||||
@override
|
||||
String get publicProfileUnreachable =>
|
||||
'Dein öffentliches Profil ist gerade nicht erreichbar.';
|
||||
|
||||
@override
|
||||
String get publicProfileRetry => 'Erneut versuchen';
|
||||
|
||||
@override
|
||||
String get profileProblemHandleInvalid =>
|
||||
'Ein Handle besteht aus 1 bis 30 Buchstaben (a bis z), Ziffern oder Unterstrichen.';
|
||||
|
||||
@override
|
||||
String get profileProblemHandleReserved =>
|
||||
'Dieses Handle ist der Instanz vorbehalten.';
|
||||
|
||||
@override
|
||||
String get profileProblemHandleTaken =>
|
||||
'Dieses Handle hat schon jemand anderes.';
|
||||
|
||||
@override
|
||||
String get profileProblemPublished =>
|
||||
'Du hast veröffentlicht: Dein Handle kann sich nicht mehr ändern und dein Profil nicht mehr gehen.';
|
||||
|
||||
@override
|
||||
String get profileProblemDisplayNameTooLong => 'Höchstens 30 Zeichen.';
|
||||
|
||||
@override
|
||||
String get profileProblemBioTooLong => 'Höchstens 500 Zeichen.';
|
||||
|
||||
@override
|
||||
String get profileProblemTooManyLinks => 'Höchstens 4 Links.';
|
||||
|
||||
@override
|
||||
String get profileProblemLinkInvalid =>
|
||||
'Links beginnen mit https:// oder http://.';
|
||||
|
||||
@override
|
||||
String get profileProblemAvatarInvalid =>
|
||||
'Dieses Bild lässt sich nicht verwenden: Wähle ein PNG-, JPEG-, WebP- oder GIF-Bild.';
|
||||
|
||||
@override
|
||||
String get publicProfilePicture => 'Bild';
|
||||
|
||||
@override
|
||||
String get publicProfileChoosePicture => 'Bild wählen';
|
||||
|
||||
@override
|
||||
String get publicProfileRemovePicture => 'Bild entfernen';
|
||||
|
||||
@override
|
||||
String get publicProfilePictureType => 'Bilder';
|
||||
|
||||
@override
|
||||
String get publicProfilePictureLater =>
|
||||
'Speichere dein Profil, um ein Bild hinzuzufügen.';
|
||||
|
||||
@override
|
||||
String get settingsTitle => 'Einstellungen';
|
||||
|
||||
|
|
|
|||
|
|
@ -1389,6 +1389,131 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get profileSwitchConfirm => 'Switch';
|
||||
|
||||
@override
|
||||
String get publicProfileTitle => 'Public profile';
|
||||
|
||||
@override
|
||||
String get publicProfileIntro =>
|
||||
'How others see you once you publish: the handle they find you by, your name, a few words and links. Nothing here shows until you save it.';
|
||||
|
||||
@override
|
||||
String get publicProfileAnonymousHint =>
|
||||
'Sign in with GitHub to have a public profile and publish.';
|
||||
|
||||
@override
|
||||
String get publicProfileDisplayName => 'Display name';
|
||||
|
||||
@override
|
||||
String get publicProfileDisplayNameHint =>
|
||||
'The name shown beside your handle';
|
||||
|
||||
@override
|
||||
String get publicProfileHandle => 'Handle';
|
||||
|
||||
@override
|
||||
String get publicProfileHandleHint =>
|
||||
'Letters, digits and underscores, up to 30';
|
||||
|
||||
@override
|
||||
String publicProfileAddress(String address) {
|
||||
return 'Others find you as @$address';
|
||||
}
|
||||
|
||||
@override
|
||||
String get publicProfileHandleFixed =>
|
||||
'Fixed since you published: others refer to it.';
|
||||
|
||||
@override
|
||||
String get publicProfileBio => 'Bio';
|
||||
|
||||
@override
|
||||
String get publicProfileLinks => 'Links';
|
||||
|
||||
@override
|
||||
String get publicProfileLinksHint => 'Your site or other accounts, up to 4.';
|
||||
|
||||
@override
|
||||
String get publicProfileLinkHint => 'https://…';
|
||||
|
||||
@override
|
||||
String get publicProfileAddLink => 'Add link';
|
||||
|
||||
@override
|
||||
String get publicProfileRemoveLink => 'Remove link';
|
||||
|
||||
@override
|
||||
String get publicProfileSaved => 'Profile saved.';
|
||||
|
||||
@override
|
||||
String get publicProfileRemove => 'Remove profile';
|
||||
|
||||
@override
|
||||
String get publicProfileRemoveTitle => 'Remove your public profile?';
|
||||
|
||||
@override
|
||||
String get publicProfileRemoveMessage =>
|
||||
'Your handle becomes free for others to take.';
|
||||
|
||||
@override
|
||||
String get publicProfileRemoved => 'Profile removed.';
|
||||
|
||||
@override
|
||||
String get publicProfileFailed => 'Couldn\'t reach the instance. Try again.';
|
||||
|
||||
@override
|
||||
String get publicProfileUnreachable =>
|
||||
'Your public profile can\'t be reached right now.';
|
||||
|
||||
@override
|
||||
String get publicProfileRetry => 'Retry';
|
||||
|
||||
@override
|
||||
String get profileProblemHandleInvalid =>
|
||||
'A handle is 1 to 30 letters (a to z), digits or underscores.';
|
||||
|
||||
@override
|
||||
String get profileProblemHandleReserved =>
|
||||
'This handle is kept for the instance.';
|
||||
|
||||
@override
|
||||
String get profileProblemHandleTaken => 'Someone else has this handle.';
|
||||
|
||||
@override
|
||||
String get profileProblemPublished =>
|
||||
'You have published: your handle can\'t change, nor your profile go.';
|
||||
|
||||
@override
|
||||
String get profileProblemDisplayNameTooLong => 'At most 30 characters.';
|
||||
|
||||
@override
|
||||
String get profileProblemBioTooLong => 'At most 500 characters.';
|
||||
|
||||
@override
|
||||
String get profileProblemTooManyLinks => 'At most 4 links.';
|
||||
|
||||
@override
|
||||
String get profileProblemLinkInvalid =>
|
||||
'Links start with https:// or http://.';
|
||||
|
||||
@override
|
||||
String get profileProblemAvatarInvalid =>
|
||||
'This picture can\'t be used: choose a PNG, JPEG, WebP or GIF image.';
|
||||
|
||||
@override
|
||||
String get publicProfilePicture => 'Picture';
|
||||
|
||||
@override
|
||||
String get publicProfileChoosePicture => 'Choose picture';
|
||||
|
||||
@override
|
||||
String get publicProfileRemovePicture => 'Remove picture';
|
||||
|
||||
@override
|
||||
String get publicProfilePictureType => 'Images';
|
||||
|
||||
@override
|
||||
String get publicProfilePictureLater => 'Save your profile to add a picture.';
|
||||
|
||||
@override
|
||||
String get settingsTitle => 'Settings';
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/sync_pdf_sourc
|
|||
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_platform.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_platforms.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_run_store.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/profile/data/serverpod_profile_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/sync_settings_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
|
|
@ -298,6 +300,7 @@ Future<PersonDependencies> _openPerson(
|
|||
pomodoroRuns: FilePomodoroRunStore(
|
||||
File(p.join(folder.path, 'pomodoro.json')),
|
||||
),
|
||||
profile: ProfileRepository(ServerpodProfileGateway(client)),
|
||||
onDispose: () async {
|
||||
try {
|
||||
// Deletions first: those still waiting are dropped, those under way
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart
|
|||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_run_store.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
|
||||
|
|
@ -75,6 +76,9 @@ class PersonDependencies({
|
|||
|
||||
/// Where this device keeps the person's Pomodoro timer.
|
||||
required final PomodoroRunStore pomodoroRuns,
|
||||
|
||||
/// The person's public profile, on the instance.
|
||||
required final ProfileRepository profile,
|
||||
final Future<void> Function()? _onDispose,
|
||||
}) {
|
||||
/// Closes what was opened for the person.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import 'package:halkyon_learn_flutter/src/core/router/tab_visibility.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_trouble_badge.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/github_sign_in_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/profile/widget/public_profile_section.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
|
|
@ -98,7 +99,21 @@ class const _Account() extends StatelessWidget {
|
|||
Text(
|
||||
isIdentified ? l10n.profileIdentified : l10n.profileAnonymous,
|
||||
),
|
||||
if (!isIdentified) const _GitHubSignIn(),
|
||||
if (!isIdentified) ...[
|
||||
const _GitHubSignIn(),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
l10n.publicProfileAnonymousHint,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
const SizedBox(height: 24),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 24),
|
||||
const PublicProfileSection(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,197 @@
|
|||
import 'dart:developer';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
|
||||
|
||||
/// The person's public profile: reading it, saving it, removing it.
|
||||
class PublicProfileBloc(final ProfileRepository _profiles)
|
||||
extends Bloc<PublicProfileEvent, PublicProfileState> {
|
||||
this : super(const PublicProfileStateLoading()) {
|
||||
on<PublicProfileEventStarted>(_onStarted);
|
||||
on<PublicProfileEventSaved>(_onSaved);
|
||||
on<PublicProfileEventRemoved>(_onRemoved);
|
||||
on<PublicProfileEventAvatarChosen>(
|
||||
(event, emit) => _change(emit, () => _profiles.setAvatar(event.picture)),
|
||||
);
|
||||
on<PublicProfileEventAvatarRemoved>(
|
||||
(event, emit) => _change(emit, _profiles.removeAvatar),
|
||||
);
|
||||
}
|
||||
|
||||
/// Changes the profile through [change], as saving it does.
|
||||
Future<void> _change(
|
||||
Emitter<PublicProfileState> emit,
|
||||
Future<PublicProfile> Function() change,
|
||||
) async {
|
||||
if (state case PublicProfileStateReady(:final profile?, saving: false)) {
|
||||
emit(PublicProfileStateReady(profile: profile, saving: true));
|
||||
try {
|
||||
emit(
|
||||
PublicProfileStateReady(
|
||||
profile: await change(),
|
||||
outcome: PublicProfileOutcome.saved,
|
||||
),
|
||||
);
|
||||
} on ProfileRefused catch (refused) {
|
||||
emit(
|
||||
PublicProfileStateReady(profile: profile, problem: refused.problem),
|
||||
);
|
||||
} on Object catch (error) {
|
||||
log('Changing the avatar failed', name: 'Profile', error: error);
|
||||
emit(
|
||||
PublicProfileStateReady(
|
||||
profile: profile,
|
||||
outcome: PublicProfileOutcome.failed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onStarted(
|
||||
PublicProfileEventStarted event,
|
||||
Emitter<PublicProfileState> emit,
|
||||
) async {
|
||||
emit(const PublicProfileStateLoading());
|
||||
try {
|
||||
emit(PublicProfileStateReady(profile: await _profiles.mine()));
|
||||
} on Object catch (error) {
|
||||
log('Reading the profile failed', name: 'Profile', error: error);
|
||||
emit(const PublicProfileStateUnreachable());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onSaved(
|
||||
PublicProfileEventSaved event,
|
||||
Emitter<PublicProfileState> emit,
|
||||
) async {
|
||||
if (state case PublicProfileStateReady(:final profile, saving: false)) {
|
||||
emit(PublicProfileStateReady(profile: profile, saving: true));
|
||||
try {
|
||||
emit(
|
||||
PublicProfileStateReady(
|
||||
profile: await _profiles.save(
|
||||
handle: event.handle,
|
||||
displayName: event.displayName,
|
||||
bio: event.bio,
|
||||
links: event.links,
|
||||
),
|
||||
outcome: PublicProfileOutcome.saved,
|
||||
),
|
||||
);
|
||||
} on ProfileRefused catch (refused) {
|
||||
emit(
|
||||
PublicProfileStateReady(profile: profile, problem: refused.problem),
|
||||
);
|
||||
} on Object catch (error) {
|
||||
log('Saving the profile failed', name: 'Profile', error: error);
|
||||
emit(
|
||||
PublicProfileStateReady(
|
||||
profile: profile,
|
||||
outcome: PublicProfileOutcome.failed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onRemoved(
|
||||
PublicProfileEventRemoved event,
|
||||
Emitter<PublicProfileState> emit,
|
||||
) async {
|
||||
if (state case PublicProfileStateReady(:final profile?, saving: false)) {
|
||||
emit(PublicProfileStateReady(profile: profile, saving: true));
|
||||
try {
|
||||
await _profiles.remove();
|
||||
emit(
|
||||
const PublicProfileStateReady(outcome: PublicProfileOutcome.removed),
|
||||
);
|
||||
} on ProfileRefused catch (refused) {
|
||||
emit(
|
||||
PublicProfileStateReady(profile: profile, problem: refused.problem),
|
||||
);
|
||||
} on Object catch (error) {
|
||||
log('Removing the profile failed', name: 'Profile', error: error);
|
||||
emit(
|
||||
PublicProfileStateReady(
|
||||
profile: profile,
|
||||
outcome: PublicProfileOutcome.failed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What saving or removing came to, said once.
|
||||
enum PublicProfileOutcome() {
|
||||
/// Saved.
|
||||
saved,
|
||||
|
||||
/// Removed.
|
||||
removed,
|
||||
|
||||
/// The instance couldn't be reached; nothing changed.
|
||||
failed,
|
||||
}
|
||||
|
||||
/// Where the person's public profile stands.
|
||||
sealed class const PublicProfileState() with Equatable {
|
||||
@override
|
||||
List<Object?> get props => const [];
|
||||
}
|
||||
|
||||
/// Being read.
|
||||
final class const PublicProfileStateLoading() extends PublicProfileState;
|
||||
|
||||
/// The instance can't be reached.
|
||||
final class const PublicProfileStateUnreachable() extends PublicProfileState;
|
||||
|
||||
/// Read: the [profile] (null while there is none), whether a change is on
|
||||
/// its way, why the last one was refused ([problem]), and what the last one
|
||||
/// came to ([outcome]); the last two cleared by the next state.
|
||||
final class const PublicProfileStateReady({
|
||||
final PublicProfile? profile,
|
||||
final bool saving = false,
|
||||
final ProfileProblem? problem,
|
||||
final PublicProfileOutcome? outcome,
|
||||
}) extends PublicProfileState {
|
||||
@override
|
||||
List<Object?> get props => [profile, saving, problem, outcome];
|
||||
}
|
||||
|
||||
/// What happens to the person's public profile.
|
||||
sealed class const PublicProfileEvent() with Equatable {
|
||||
@override
|
||||
List<Object?> get props => const [];
|
||||
}
|
||||
|
||||
/// The profile shows: read it.
|
||||
final class const PublicProfileEventStarted() extends PublicProfileEvent;
|
||||
|
||||
/// The person saves their profile as written.
|
||||
final class const PublicProfileEventSaved({
|
||||
required final String handle,
|
||||
required final String displayName,
|
||||
required final String bio,
|
||||
required final List<String> links,
|
||||
}) extends PublicProfileEvent {
|
||||
@override
|
||||
List<Object?> get props => [handle, displayName, bio, links];
|
||||
}
|
||||
|
||||
/// The person removes their profile.
|
||||
final class const PublicProfileEventRemoved() extends PublicProfileEvent;
|
||||
|
||||
/// The person chose [picture] as their avatar.
|
||||
final class const PublicProfileEventAvatarChosen(final Uint8List picture)
|
||||
extends PublicProfileEvent {
|
||||
@override
|
||||
List<Object?> get props => [picture];
|
||||
}
|
||||
|
||||
/// The person removes their avatar.
|
||||
final class const PublicProfileEventAvatarRemoved() extends PublicProfileEvent;
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
/// The side of an avatar, in pixels.
|
||||
const avatarSide = 400;
|
||||
|
||||
/// [picture] as an avatar: its middle square, drawn anew at [avatarSide]
|
||||
/// pixels a side (or its own, if smaller), encoded as a PNG. Drawing it
|
||||
/// anew leaves out everything but the pixels: metadata such as where a
|
||||
/// photo was taken stays on the device. Throws for what isn't a picture.
|
||||
Future<Uint8List> avatarPng(Uint8List picture) async {
|
||||
final codec = await ui.instantiateImageCodec(picture);
|
||||
final ui.Image source;
|
||||
try {
|
||||
source = (await codec.getNextFrame()).image;
|
||||
} finally {
|
||||
codec.dispose();
|
||||
}
|
||||
try {
|
||||
final square = math.min(source.width, source.height);
|
||||
final side = math.min(square, avatarSide);
|
||||
final recorder = ui.PictureRecorder();
|
||||
ui.Canvas(recorder).drawImageRect(
|
||||
source,
|
||||
ui.Rect.fromLTWH(
|
||||
(source.width - square) / 2,
|
||||
(source.height - square) / 2,
|
||||
square.toDouble(),
|
||||
square.toDouble(),
|
||||
),
|
||||
ui.Rect.fromLTWH(0, 0, side.toDouble(), side.toDouble()),
|
||||
ui.Paint()..filterQuality = ui.FilterQuality.high,
|
||||
);
|
||||
final drawn = await recorder.endRecording().toImage(side, side);
|
||||
try {
|
||||
final png = await drawn.toByteData(format: ui.ImageByteFormat.png);
|
||||
return png!.buffer.asUint8List(png.offsetInBytes, png.lengthInBytes);
|
||||
} finally {
|
||||
drawn.dispose();
|
||||
}
|
||||
} finally {
|
||||
source.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
|
||||
|
||||
/// The boundary to the person's public profile on the instance. Tests
|
||||
/// replace it with a fake.
|
||||
abstract interface class ProfileGateway() {
|
||||
/// The person's public profile; null while they have none.
|
||||
Future<PublicProfile?> mine();
|
||||
|
||||
/// Saves the person's public profile, making it if they have none.
|
||||
/// Throws [ProfileRefused] when the instance refuses it.
|
||||
Future<PublicProfile> save({
|
||||
required String handle,
|
||||
required String displayName,
|
||||
required String bio,
|
||||
required List<String> links,
|
||||
});
|
||||
|
||||
/// Removes the person's public profile. Throws [ProfileRefused] once they
|
||||
/// have published.
|
||||
Future<void> remove();
|
||||
|
||||
/// Shows [png] as the person's avatar; returns the profile with it.
|
||||
/// Throws [ProfileRefused] for anything but a PNG of at most 1 MB.
|
||||
Future<PublicProfile> setAvatar(Uint8List png);
|
||||
|
||||
/// Shows no avatar any more; returns the profile without it.
|
||||
Future<PublicProfile> removeAvatar();
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_learn_flutter/src/feature/profile/data/avatar_image.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
|
||||
|
||||
/// The person's public profile (ADR 0005, rule 2). It lives on the
|
||||
/// instance, not in the person's synced data, so it needs the instance to
|
||||
/// be reachable. A picture becomes an avatar through [_drawAvatar]
|
||||
/// ([avatarPng] unless a test gives another).
|
||||
class ProfileRepository(
|
||||
final ProfileGateway _gateway, {
|
||||
final Future<Uint8List> Function(Uint8List picture) _drawAvatar = avatarPng,
|
||||
}) {
|
||||
/// The person's public profile; null while they have none.
|
||||
Future<PublicProfile?> mine() => _gateway.mine();
|
||||
|
||||
/// Saves the person's public profile, blank links left out. Throws
|
||||
/// [ProfileRefused] when the instance refuses it.
|
||||
Future<PublicProfile> save({
|
||||
required String handle,
|
||||
required String displayName,
|
||||
required String bio,
|
||||
required List<String> links,
|
||||
}) => _gateway.save(
|
||||
handle: handle.trim(),
|
||||
displayName: displayName.trim(),
|
||||
bio: bio.trim(),
|
||||
links: [
|
||||
for (final link in links)
|
||||
if (link.trim().isNotEmpty) link.trim(),
|
||||
],
|
||||
);
|
||||
|
||||
/// Removes the person's public profile. Throws [ProfileRefused] once they
|
||||
/// have published.
|
||||
Future<void> remove() => _gateway.remove();
|
||||
|
||||
/// Shows [picture] (a PNG, JPEG, WebP or GIF) as the person's avatar,
|
||||
/// drawn anew as a square PNG ([avatarPng]): metadata such as where a
|
||||
/// photo was taken never leaves the device. Throws [ProfileRefused] for
|
||||
/// a picture that can't be read.
|
||||
Future<PublicProfile> setAvatar(Uint8List picture) async {
|
||||
final Uint8List png;
|
||||
try {
|
||||
png = await _drawAvatar(picture);
|
||||
} on Object {
|
||||
throw ProfileRefused(ProfileProblem.avatarInvalid);
|
||||
}
|
||||
return await _gateway.setAvatar(png);
|
||||
}
|
||||
|
||||
/// Shows no avatar any more.
|
||||
Future<PublicProfile> removeAvatar() => _gateway.removeAvatar();
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
|
||||
|
||||
/// [ProfileGateway] asking the instance through the Serverpod client.
|
||||
class ServerpodProfileGateway(final protocol.Client _client)
|
||||
implements ProfileGateway {
|
||||
@override
|
||||
Future<PublicProfile?> mine() async => switch (await _client.profile.mine()) {
|
||||
final profile? => _profile(profile),
|
||||
null => null,
|
||||
};
|
||||
|
||||
@override
|
||||
Future<PublicProfile> save({
|
||||
required String handle,
|
||||
required String displayName,
|
||||
required String bio,
|
||||
required List<String> links,
|
||||
}) => _refusing(
|
||||
() async => _profile(
|
||||
await _client.profile.save(handle, displayName, bio, links),
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> remove() => _refusing(_client.profile.remove);
|
||||
|
||||
@override
|
||||
Future<PublicProfile> setAvatar(Uint8List png) => _refusing(
|
||||
() async => _profile(
|
||||
await _client.profile.setAvatar(ByteData.sublistView(png)),
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<PublicProfile> removeAvatar() =>
|
||||
_refusing(() async => _profile(await _client.profile.removeAvatar()));
|
||||
|
||||
/// [call], its refusal as a [ProfileRefused]; a reason this app doesn't
|
||||
/// know reads as an invalid handle.
|
||||
Future<T> _refusing<T>(Future<T> Function() call) async {
|
||||
try {
|
||||
return await call();
|
||||
} on protocol.ProfileRefusedException catch (error) {
|
||||
throw ProfileRefused(
|
||||
ProfileProblem.values.asNameMap()[error.reason.name] ??
|
||||
ProfileProblem.handleInvalid,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PublicProfile _profile(protocol.Profile profile) => PublicProfile(
|
||||
handle: profile.handle,
|
||||
address: profile.address,
|
||||
displayName: profile.displayName,
|
||||
bio: profile.bio,
|
||||
links: profile.links,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
handleFixed: profile.handleFixed,
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// A person's public profile (ADR 0005, rule 2): the handle others find
|
||||
/// them by, at the instance's account domain ([address],
|
||||
/// `raisondetredev@halkyon.eu`), a display name, a bio, links and an
|
||||
/// avatar.
|
||||
final class const PublicProfile({
|
||||
/// The handle as its person wrote it.
|
||||
required final String handle,
|
||||
|
||||
/// The handle with the account domain.
|
||||
required final String address,
|
||||
|
||||
/// The name shown beside the handle; empty for none.
|
||||
final String displayName = '',
|
||||
|
||||
/// A few words about the person; empty for none.
|
||||
final String bio = '',
|
||||
|
||||
/// Pages about the person, in their order.
|
||||
final List<String> links = const [],
|
||||
|
||||
/// Where their avatar is, a PNG; null for none.
|
||||
final String? avatarUrl,
|
||||
|
||||
/// Whether the handle is fixed: its person has published.
|
||||
final bool handleFixed = false,
|
||||
}) with Equatable {
|
||||
/// The most links a profile holds, as the instance allows.
|
||||
static const maxLinks = 4;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
handle,
|
||||
address,
|
||||
displayName,
|
||||
bio,
|
||||
links,
|
||||
avatarUrl,
|
||||
handleFixed,
|
||||
];
|
||||
}
|
||||
|
||||
/// Why the instance refused to save or remove a profile.
|
||||
enum ProfileProblem() {
|
||||
/// The handle isn't 1 to 30 letters, digits or underscores.
|
||||
handleInvalid,
|
||||
|
||||
/// The handle is kept for the instance.
|
||||
handleReserved,
|
||||
|
||||
/// Someone else has the handle.
|
||||
handleTaken,
|
||||
|
||||
/// The person has published: the handle is fixed, the profile stays.
|
||||
published,
|
||||
|
||||
/// The display name is too long.
|
||||
displayNameTooLong,
|
||||
|
||||
/// The bio is too long.
|
||||
bioTooLong,
|
||||
|
||||
/// Too many links.
|
||||
tooManyLinks,
|
||||
|
||||
/// A link isn't an absolute HTTP(S) URL.
|
||||
linkInvalid,
|
||||
|
||||
/// The picture can't be read, or isn't a PNG of at most 1 MB.
|
||||
avatarInvalid,
|
||||
}
|
||||
|
||||
/// The instance refused to save or remove a profile, for [problem];
|
||||
/// nothing changed.
|
||||
final class ProfileRefused(final ProfileProblem problem) implements Exception {
|
||||
@override
|
||||
String toString() => 'ProfileRefused($problem)';
|
||||
}
|
||||
|
|
@ -0,0 +1,386 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/profile/bloc/public_profile_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The person's public profile (ADR 0005, rule 2), in the Profile tab: what
|
||||
/// it is for, and a form of its display name, handle, bio and links, saved
|
||||
/// at once; a way to remove it until the person publishes.
|
||||
class const PublicProfileSection({super.key}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => BlocProvider(
|
||||
create: (context) =>
|
||||
PublicProfileBloc(PersonScope.of(context).profile)
|
||||
..add(const PublicProfileEventStarted()),
|
||||
child: const _Section(),
|
||||
);
|
||||
}
|
||||
|
||||
class const _Section() extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return BlocConsumer<PublicProfileBloc, PublicProfileState>(
|
||||
listenWhen: (before, now) =>
|
||||
now is PublicProfileStateReady && now.outcome != null,
|
||||
listener: (context, state) {
|
||||
final message = switch (state) {
|
||||
PublicProfileStateReady(outcome: PublicProfileOutcome.saved) =>
|
||||
l10n.publicProfileSaved,
|
||||
PublicProfileStateReady(outcome: PublicProfileOutcome.removed) =>
|
||||
l10n.publicProfileRemoved,
|
||||
_ => l10n.publicProfileFailed,
|
||||
};
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(content: Text(message)));
|
||||
},
|
||||
builder: (context, state) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(l10n.publicProfileTitle, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.publicProfileIntro,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
switch (state) {
|
||||
PublicProfileStateLoading() => const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(child: AppLoadingIndicator()),
|
||||
),
|
||||
PublicProfileStateUnreachable() => Column(
|
||||
children: [
|
||||
Text(l10n.publicProfileUnreachable),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => context.read<PublicProfileBloc>().add(
|
||||
const PublicProfileEventStarted(),
|
||||
),
|
||||
child: Text(l10n.publicProfileRetry),
|
||||
),
|
||||
],
|
||||
),
|
||||
PublicProfileStateReady(
|
||||
:final profile,
|
||||
:final saving,
|
||||
:final problem,
|
||||
) =>
|
||||
_ProfileForm(
|
||||
// Each profile the instance answers with starts the form
|
||||
// afresh, with what it holds.
|
||||
key: ValueKey(profile),
|
||||
profile: profile,
|
||||
saving: saving,
|
||||
problem: problem,
|
||||
),
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class const _ProfileForm({
|
||||
required final PublicProfile? profile,
|
||||
required final bool saving,
|
||||
required final ProfileProblem? problem,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<_ProfileForm> createState() => _ProfileFormState();
|
||||
}
|
||||
|
||||
class _ProfileFormState() extends State<_ProfileForm> {
|
||||
late final _displayName = TextEditingController(
|
||||
text: widget.profile?.displayName,
|
||||
);
|
||||
late final _handle = TextEditingController(text: widget.profile?.handle);
|
||||
late final _bio = TextEditingController(text: widget.profile?.bio);
|
||||
late final _links = [
|
||||
for (final link in widget.profile?.links ?? const <String>[])
|
||||
TextEditingController(text: link),
|
||||
];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_displayName.dispose();
|
||||
_handle.dispose();
|
||||
_bio.dispose();
|
||||
for (final link in _links) {
|
||||
link.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _save() => context.read<PublicProfileBloc>().add(
|
||||
PublicProfileEventSaved(
|
||||
handle: _handle.text,
|
||||
displayName: _displayName.text,
|
||||
bio: _bio.text,
|
||||
links: [for (final link in _links) link.text],
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> _remove() async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final bloc = context.read<PublicProfileBloc>();
|
||||
if (await confirmDestructiveAction(
|
||||
context,
|
||||
title: l10n.publicProfileRemoveTitle,
|
||||
message: l10n.publicProfileRemoveMessage,
|
||||
confirmLabel: l10n.publicProfileRemove,
|
||||
)) {
|
||||
bloc.add(const PublicProfileEventRemoved());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final profile = widget.profile;
|
||||
final fixed = profile?.handleFixed ?? false;
|
||||
String? said(Set<ProfileProblem> about) => switch (widget.problem) {
|
||||
final problem? when about.contains(problem) => _problemText(
|
||||
l10n,
|
||||
problem,
|
||||
),
|
||||
_ => null,
|
||||
};
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_Picture(
|
||||
profile: profile,
|
||||
saving: widget.saving,
|
||||
problem: said({ProfileProblem.avatarInvalid}),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _displayName,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.publicProfileDisplayName,
|
||||
hintText: l10n.publicProfileDisplayNameHint,
|
||||
errorText: said({ProfileProblem.displayNameTooLong}),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _handle,
|
||||
readOnly: fixed,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.publicProfileHandle,
|
||||
hintText: l10n.publicProfileHandleHint,
|
||||
prefixText: '@',
|
||||
helperText: switch (profile) {
|
||||
PublicProfile(handleFixed: true) => l10n.publicProfileHandleFixed,
|
||||
final profile? => l10n.publicProfileAddress(profile.address),
|
||||
null => null,
|
||||
},
|
||||
errorText: said({
|
||||
ProfileProblem.handleInvalid,
|
||||
ProfileProblem.handleReserved,
|
||||
ProfileProblem.handleTaken,
|
||||
ProfileProblem.published,
|
||||
}),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _bio,
|
||||
minLines: 3,
|
||||
maxLines: null,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.publicProfileBio,
|
||||
errorText: said({ProfileProblem.bioTooLong}),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(l10n.publicProfileLinks, style: theme.textTheme.titleMedium),
|
||||
Text(
|
||||
said({ProfileProblem.tooManyLinks, ProfileProblem.linkInvalid}) ??
|
||||
l10n.publicProfileLinksHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color:
|
||||
widget.problem == ProfileProblem.tooManyLinks ||
|
||||
widget.problem == ProfileProblem.linkInvalid
|
||||
? theme.colorScheme.error
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (final (i, link) in _links.indexed)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: link,
|
||||
keyboardType: TextInputType.url,
|
||||
autocorrect: false,
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.publicProfileLinkHint,
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.publicProfileRemoveLink,
|
||||
icon: const Icon(LucideIcons.x, size: 18),
|
||||
onPressed: () => setState(() => _links.removeAt(i).dispose()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_links.length < PublicProfile.maxLinks)
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: TextButton.icon(
|
||||
onPressed: () =>
|
||||
setState(() => _links.add(TextEditingController())),
|
||||
icon: const Icon(LucideIcons.plus, size: 18),
|
||||
label: Text(l10n.publicProfileAddLink),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
if (profile != null && !fixed)
|
||||
TextButton(
|
||||
onPressed: widget.saving ? null : _remove,
|
||||
child: Text(l10n.publicProfileRemove),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton(
|
||||
onPressed: widget.saving ? null : _save,
|
||||
child: Text(l10n.commonSave),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The avatar, round, with ways to choose another or remove it; once the
|
||||
/// profile is saved, as an avatar needs a profile to show on.
|
||||
class const _Picture({
|
||||
required final PublicProfile? profile,
|
||||
required final bool saving,
|
||||
required final String? problem,
|
||||
}) extends StatelessWidget {
|
||||
Future<void> _choose(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final bloc = context.read<PublicProfileBloc>();
|
||||
final picked = await DependenciesScope.of(context).files.pick(
|
||||
label: l10n.publicProfilePictureType,
|
||||
extensions: const ['png', 'jpg', 'jpeg', 'webp', 'gif'],
|
||||
);
|
||||
if (picked != null) {
|
||||
bloc.add(PublicProfileEventAvatarChosen(picked.bytes));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final profile = this.profile;
|
||||
final url = profile?.avatarUrl;
|
||||
return Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 32,
|
||||
backgroundColor: theme.colorScheme.surfaceContainerHighest,
|
||||
child: ClipOval(
|
||||
child: url == null
|
||||
? Icon(
|
||||
LucideIcons.user,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
: Image.network(
|
||||
url,
|
||||
width: 64,
|
||||
height: 64,
|
||||
fit: BoxFit.cover,
|
||||
semanticLabel: l10n.publicProfilePicture,
|
||||
errorBuilder: (context, _, _) => Icon(
|
||||
LucideIcons.user,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: profile == null
|
||||
? Text(
|
||||
l10n.publicProfilePictureLater,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: saving ? null : () => _choose(context),
|
||||
icon: const Icon(LucideIcons.image, size: 18),
|
||||
label: Text(l10n.publicProfileChoosePicture),
|
||||
),
|
||||
if (url != null)
|
||||
TextButton(
|
||||
onPressed: saving
|
||||
? null
|
||||
: () => context.read<PublicProfileBloc>().add(
|
||||
const PublicProfileEventAvatarRemoved(),
|
||||
),
|
||||
child: Text(l10n.publicProfileRemovePicture),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (problem case final problem?)
|
||||
Text(
|
||||
problem,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _problemText(AppLocalizations l10n, ProfileProblem problem) =>
|
||||
switch (problem) {
|
||||
ProfileProblem.handleInvalid => l10n.profileProblemHandleInvalid,
|
||||
ProfileProblem.handleReserved => l10n.profileProblemHandleReserved,
|
||||
ProfileProblem.handleTaken => l10n.profileProblemHandleTaken,
|
||||
ProfileProblem.published => l10n.profileProblemPublished,
|
||||
ProfileProblem.displayNameTooLong =>
|
||||
l10n.profileProblemDisplayNameTooLong,
|
||||
ProfileProblem.bioTooLong => l10n.profileProblemBioTooLong,
|
||||
ProfileProblem.tooManyLinks => l10n.profileProblemTooManyLinks,
|
||||
ProfileProblem.linkInvalid => l10n.profileProblemLinkInvalid,
|
||||
ProfileProblem.avatarInvalid => l10n.profileProblemAvatarInvalid,
|
||||
};
|
||||
|
|
@ -15,6 +15,7 @@ import 'package:halkyon_learn_flutter/src/feature/note/data/note_deletions.dart'
|
|||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
|
||||
|
|
@ -29,6 +30,7 @@ import '../../support/fake_learning_path_gateway.dart';
|
|||
import '../../support/fake_note_gateway.dart';
|
||||
import '../../support/fake_path_answer_gateway.dart';
|
||||
import '../../support/fake_pdf_source_gateway.dart';
|
||||
import '../../support/fake_profile_gateway.dart';
|
||||
import '../../support/fake_review_gateway.dart';
|
||||
import '../../support/fake_settings_gateway.dart';
|
||||
import '../../support/fake_studyset_gateway.dart';
|
||||
|
|
@ -68,6 +70,7 @@ PersonDependencies personDependencies({Future<void> Function()? onDispose}) {
|
|||
stepDeletions: StepDeletions(paths),
|
||||
pdfDeletions: PdfDeletions(pdfSources),
|
||||
pomodoroRuns: MemoryPomodoroRunStore(),
|
||||
profile: ProfileRepository(FakeProfileGateway()),
|
||||
onDispose: onDispose,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/profile/data/avatar_image.dart';
|
||||
|
||||
/// A PNG [width] × [height], red on its left half, blue on its right.
|
||||
Future<Uint8List> picture(int width, int height) async {
|
||||
final recorder = ui.PictureRecorder();
|
||||
ui.Canvas(recorder)
|
||||
..drawRect(
|
||||
ui.Rect.fromLTWH(0, 0, width / 2, height.toDouble()),
|
||||
ui.Paint()..color = const ui.Color(0xFFFF0000),
|
||||
)
|
||||
..drawRect(
|
||||
ui.Rect.fromLTWH(width / 2, 0, width / 2, height.toDouble()),
|
||||
ui.Paint()..color = const ui.Color(0xFF0000FF),
|
||||
);
|
||||
final image = await recorder.endRecording().toImage(width, height);
|
||||
final png = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
return png!.buffer.asUint8List();
|
||||
}
|
||||
|
||||
Future<ui.Image> decoded(Uint8List png) async {
|
||||
final codec = await ui.instantiateImageCodec(png);
|
||||
return (await codec.getNextFrame()).image;
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('a picture becomes its middle square, at most 400 pixels a side, as '
|
||||
'a PNG', () async {
|
||||
final wide = await avatarPng(await picture(1200, 600));
|
||||
final image = await decoded(wide);
|
||||
|
||||
expect((image.width, image.height), (400, 400));
|
||||
expect(wide.sublist(0, 8), [
|
||||
0x89,
|
||||
0x50,
|
||||
0x4e,
|
||||
0x47,
|
||||
0x0d,
|
||||
0x0a,
|
||||
0x1a,
|
||||
0x0a,
|
||||
]);
|
||||
// The middle square of a picture red and blue side by side holds both.
|
||||
final pixels = (await image.toByteData())!;
|
||||
expect(pixels.getUint8(0), greaterThan(200));
|
||||
expect(pixels.getUint8(399 * 4 + 2), greaterThan(200));
|
||||
});
|
||||
|
||||
test('a small picture keeps its size', () async {
|
||||
final image = await decoded(await avatarPng(await picture(100, 160)));
|
||||
|
||||
expect((image.width, image.height), (100, 100));
|
||||
});
|
||||
|
||||
test('what is no picture is refused', () async {
|
||||
await expectLater(
|
||||
avatarPng(Uint8List.fromList([1, 2, 3])),
|
||||
throwsA(anything),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../../support/fake_auth_gateway.dart';
|
||||
import '../../support/fake_file_picker.dart';
|
||||
import '../../support/fake_profile_gateway.dart';
|
||||
import '../../support/pump_app.dart';
|
||||
import '../../support/test_dependencies.dart';
|
||||
|
||||
void main() {
|
||||
/// Opens the Profile tab of a person with an identity, whose profile the
|
||||
/// instance answers through [profiles].
|
||||
Future<void> showProfile(
|
||||
WidgetTester tester,
|
||||
FakeProfileGateway profiles, {
|
||||
bool identified = true,
|
||||
FakeFilePicker? files,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(1000, 1800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(
|
||||
authGateway: FakeAuthGateway(
|
||||
status: SignedIn(
|
||||
authUserId: 'github-person',
|
||||
isIdentified: identified,
|
||||
),
|
||||
),
|
||||
profileGateway: profiles,
|
||||
files: files,
|
||||
),
|
||||
location: RoutePaths.profile,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Finder field(String label) => find.widgetWithText(TextField, label);
|
||||
|
||||
testWidgets('a person with an identity writes a public profile and saves '
|
||||
'it, then sees the address others find them by', (tester) async {
|
||||
final profiles = FakeProfileGateway();
|
||||
await showProfile(tester, profiles);
|
||||
|
||||
expect(find.text('Public profile'), findsOneWidget);
|
||||
await tester.enterText(field('Display name'), 'Tizian');
|
||||
await tester.enterText(field('Handle'), 'raisondetredev');
|
||||
await tester.enterText(field('Bio'), 'Learning Japanese.');
|
||||
await tester.tap(find.text('Add link'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'https://…'),
|
||||
'https://halkyon.app',
|
||||
);
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Profile saved.'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Others find you as @raisondetredev@halkyon.eu'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(profiles.profile!.links, ['https://halkyon.app']);
|
||||
expect(profiles.profile!.displayName, 'Tizian');
|
||||
});
|
||||
|
||||
testWidgets('a handle someone else has is refused, said under it', (
|
||||
tester,
|
||||
) async {
|
||||
await showProfile(tester, FakeProfileGateway(taken: {'Tizian'}));
|
||||
|
||||
await tester.enterText(field('Handle'), 'tizian');
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Someone else has this handle.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('once its person has published, the handle is fixed and the '
|
||||
'profile stays', (tester) async {
|
||||
await showProfile(
|
||||
tester,
|
||||
FakeProfileGateway(
|
||||
profile: const PublicProfile(
|
||||
handle: 'raisondetredev',
|
||||
address: 'raisondetredev@halkyon.eu',
|
||||
handleFixed: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
find.text('Fixed since you published: others refer to it.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
tester.widget<TextField>(field('Handle')).readOnly,
|
||||
isTrue,
|
||||
);
|
||||
expect(find.text('Remove profile'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('removing a profile asks first, then frees its handle', (
|
||||
tester,
|
||||
) async {
|
||||
final profiles = FakeProfileGateway(
|
||||
profile: const PublicProfile(
|
||||
handle: 'raisondetredev',
|
||||
address: 'raisondetredev@halkyon.eu',
|
||||
),
|
||||
);
|
||||
await showProfile(tester, profiles);
|
||||
|
||||
await tester.tap(find.text('Remove profile'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Remove your public profile?'), findsOneWidget);
|
||||
await tester.tap(find.text('Remove profile').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Profile removed.'), findsOneWidget);
|
||||
expect(profiles.profile, isNull);
|
||||
});
|
||||
|
||||
testWidgets('a saved profile takes a picture as its avatar, and gives it '
|
||||
'up', (tester) async {
|
||||
final profiles = FakeProfileGateway(
|
||||
profile: const PublicProfile(
|
||||
handle: 'raisondetredev',
|
||||
address: 'raisondetredev@halkyon.eu',
|
||||
),
|
||||
);
|
||||
final files = FakeFilePicker()
|
||||
..next = (name: 'me.jpg', bytes: Uint8List.fromList([1, 2, 3]));
|
||||
await showProfile(tester, profiles, files: files);
|
||||
|
||||
await tester.tap(find.text('Choose picture'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(profiles.avatars.single, [1, 2, 3]);
|
||||
expect(files.asked.single, contains('jpg'));
|
||||
expect(find.text('Profile saved.'), findsOneWidget);
|
||||
await tester.tap(find.text('Remove picture'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(profiles.profile!.avatarUrl, isNull);
|
||||
expect(find.text('Remove picture'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a profile not yet saved has no picture to choose', (
|
||||
tester,
|
||||
) async {
|
||||
await showProfile(tester, FakeProfileGateway());
|
||||
|
||||
expect(find.text('Save your profile to add a picture.'), findsOneWidget);
|
||||
expect(find.text('Choose picture'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('an anonymous person is told how to have one', (tester) async {
|
||||
await showProfile(tester, FakeProfileGateway(), identified: false);
|
||||
|
||||
expect(
|
||||
find.text('Sign in with GitHub to have a public profile and publish.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Public profile'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('without the instance, it says so and tries again', (
|
||||
tester,
|
||||
) async {
|
||||
final profiles = FakeProfileGateway()..fail = Exception('offline');
|
||||
await showProfile(tester, profiles);
|
||||
|
||||
expect(
|
||||
find.text("Your public profile can't be reached right now."),
|
||||
findsOneWidget,
|
||||
);
|
||||
profiles.fail = null;
|
||||
await tester.tap(find.text('Retry'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(field('Handle'), findsOneWidget);
|
||||
});
|
||||
|
||||
test(
|
||||
'the repository leaves blank links out and trims what it saves',
|
||||
() async {
|
||||
final profiles = FakeProfileGateway();
|
||||
await ProfileRepository(profiles).save(
|
||||
handle: ' tizian ',
|
||||
displayName: ' Tizian ',
|
||||
bio: '',
|
||||
links: ['', ' https://halkyon.app '],
|
||||
);
|
||||
|
||||
expect(profiles.profile!.handle, 'tizian');
|
||||
expect(profiles.profile!.links, ['https://halkyon.app']);
|
||||
},
|
||||
);
|
||||
}
|
||||
103
halkyon_learn_flutter/test/support/fake_profile_gateway.dart
Normal file
103
halkyon_learn_flutter/test/support/fake_profile_gateway.dart
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
|
||||
|
||||
/// A [ProfileGateway] in memory, on an instance whose account domain is
|
||||
/// `halkyon.eu`: one profile, refusing the handles others have (whatever
|
||||
/// their case) and, once its handle is fixed, any other handle.
|
||||
class FakeProfileGateway({
|
||||
/// The person's profile; null while they have none.
|
||||
var PublicProfile? profile,
|
||||
|
||||
/// Handles others have.
|
||||
final Set<String> taken = const {},
|
||||
}) implements ProfileGateway {
|
||||
/// When set, every request fails with it.
|
||||
Exception? fail;
|
||||
|
||||
@override
|
||||
Future<PublicProfile?> mine() async {
|
||||
if (fail case final error?) {
|
||||
throw error;
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PublicProfile> save({
|
||||
required String handle,
|
||||
required String displayName,
|
||||
required String bio,
|
||||
required List<String> links,
|
||||
}) async {
|
||||
if (fail case final error?) {
|
||||
throw error;
|
||||
}
|
||||
final current = profile;
|
||||
if (!RegExp(r'^[A-Za-z0-9_]{1,30}$').hasMatch(handle)) {
|
||||
throw ProfileRefused(ProfileProblem.handleInvalid);
|
||||
}
|
||||
if (taken.any((other) => other.toLowerCase() == handle.toLowerCase())) {
|
||||
throw ProfileRefused(ProfileProblem.handleTaken);
|
||||
}
|
||||
if (current != null &&
|
||||
current.handleFixed &&
|
||||
current.handle.toLowerCase() != handle.toLowerCase()) {
|
||||
throw ProfileRefused(ProfileProblem.published);
|
||||
}
|
||||
return profile = PublicProfile(
|
||||
handle: handle,
|
||||
address: '$handle@halkyon.eu',
|
||||
displayName: displayName,
|
||||
bio: bio,
|
||||
links: links,
|
||||
handleFixed: current?.handleFixed ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
/// The avatars set, in order.
|
||||
final avatars = <Uint8List>[];
|
||||
|
||||
@override
|
||||
Future<PublicProfile> setAvatar(Uint8List png) async {
|
||||
if (fail case final error?) {
|
||||
throw error;
|
||||
}
|
||||
avatars.add(png);
|
||||
final current = profile!;
|
||||
return profile = PublicProfile(
|
||||
handle: current.handle,
|
||||
address: current.address,
|
||||
displayName: current.displayName,
|
||||
bio: current.bio,
|
||||
links: current.links,
|
||||
avatarUrl: 'https://learn.halkyon.eu/avatars/${avatars.length}.png',
|
||||
handleFixed: current.handleFixed,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PublicProfile> removeAvatar() async {
|
||||
final current = profile!;
|
||||
return profile = PublicProfile(
|
||||
handle: current.handle,
|
||||
address: current.address,
|
||||
displayName: current.displayName,
|
||||
bio: current.bio,
|
||||
links: current.links,
|
||||
handleFixed: current.handleFixed,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> remove() async {
|
||||
if (fail case final error?) {
|
||||
throw error;
|
||||
}
|
||||
if (profile?.handleFixed ?? false) {
|
||||
throw ProfileRefused(ProfileProblem.published);
|
||||
}
|
||||
profile = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -42,6 +42,8 @@ import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_gat
|
|||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_platform.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_run_store.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_gateway.dart';
|
||||
|
|
@ -66,6 +68,7 @@ import 'fake_path_answer_gateway.dart';
|
|||
import 'fake_pdf_engine.dart';
|
||||
import 'fake_pdf_source_gateway.dart';
|
||||
import 'fake_pomodoro_platform.dart';
|
||||
import 'fake_profile_gateway.dart';
|
||||
import 'fake_review_gateway.dart';
|
||||
import 'fake_settings_gateway.dart';
|
||||
import 'fake_sound_player.dart';
|
||||
|
|
@ -118,6 +121,7 @@ Dependencies testDependencies({
|
|||
AppVersion appVersion = const AppVersion(version: '1.0.0', build: '1'),
|
||||
PomodoroPlatform? pomodoro,
|
||||
PomodoroRunStore? pomodoroRuns,
|
||||
ProfileGateway? profileGateway,
|
||||
SyncOnce? syncOnce,
|
||||
OpenPerson? openPerson,
|
||||
}) => Dependencies(
|
||||
|
|
@ -208,6 +212,12 @@ Dependencies testDependencies({
|
|||
stepDeletions: stepDeletions,
|
||||
pdfDeletions: pdfDeletions,
|
||||
pomodoroRuns: pomodoroRuns ?? MemoryPomodoroRunStore(),
|
||||
// The picture as it is: drawing it anew is real work a widget
|
||||
// test's fake clock can't wait for (avatar_image_test.dart has it).
|
||||
profile: ProfileRepository(
|
||||
profileGateway ?? FakeProfileGateway(),
|
||||
drawAvatar: (picture) async => picture,
|
||||
),
|
||||
onDispose: () async {
|
||||
await deletions.dispose();
|
||||
await noteDeletions.dispose();
|
||||
|
|
|
|||
Loading…
Reference in a new issue