feat(app): one Profile for every app, the person's own tab first, then each app's (ADR 0014)

The public profile was already one per person for every service of an
instance (the register, ADR 0016); its app side was Learn's alone. It
moves into halkyon_app: the model, the repository on the module's
endpoints, the form, the avatar drawn on the device and the sheet with
someone's profile. They take the Public prefix (PublicProfileRepository,
BasePublicProfileGateway), as Train's own ProfileRepository holds the
body's figures. AppDependencies gains profiles and files, the picker the
avatar is chosen with, which moves out of Learn too.

HalkyonProfile is every app's Profile screen: the person's own tab first,
the same in every app (the account and the ways they sign in, the app's
own cards, then the public profile), then the app's own tabs, folding into
the title. Learn adds its inbox and Statistics. Train adds Progress, Days
and, where nothing records, Plans, and so opens on the person's tab, no
longer on Progress. Todo, which has no Profile tab, opens it from a row at
the top of Browse, where Todoist keeps the account.

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-02 09:44:38 +01:00
commit b7b0b937fe
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
25 changed files with 1668 additions and 1293 deletions

View file

@ -116,6 +116,17 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
it `hasSnackBars`). An instance out of reach signs out on the device all
the same (Serverpod's `signOutDevice`), so only the device's own storage
fails a sign-out.
- **The Profile** (`lib/src/profile/`, decided 2026-11-02): every app's
Profile screen (`HalkyonProfile`), the person's own tab first (the account
and the ways they sign in, the app's own cards, their public profile), then
the app's own tabs, the row folding into the title; and the public profile
itself, one per person for every app (halkyon_base's register, ADR 0016):
`PublicProfileRepository` on `BasePublicProfileGateway`, the form
(`PublicProfileSection`, saving as it is written), the avatar drawn anew as
a PNG on the device (`avatarPng`), and someone's profile in a sheet
(`showProfileOf`). `AppDependencies.profiles` holds it, and
`AppDependencies.files` the picker (`FilePicker`, `FileSelectorFilePicker`)
its picture is chosen with.
- **The session** (`lib/src/auth/`): `AuthRepository` on an `AuthGateway`;
`ServerpodAuthGateway` follows the session the app's Serverpod client
keeps, signs out, deletes the account through the instance's

View file

@ -95,6 +95,7 @@ export 'src/auth/widget/session_gate.dart';
export 'src/auth/widget/start_screen.dart';
export 'src/auth/widget/wait_screen.dart';
export 'src/base/base_client.dart';
export 'src/files/file_picker.dart';
export 'src/files/file_saver.dart';
export 'src/handle/bloc/handle_check_cubit.dart';
export 'src/handle/model/handle_problem.dart';
@ -153,6 +154,15 @@ export 'src/password/widget/secret_screen.dart';
export 'src/person/person_folders.dart';
export 'src/privacy/content_cipher.dart';
export 'src/privacy/payload_envelope.dart';
export 'src/profile/bloc/public_profile_bloc.dart';
export 'src/profile/data/avatar_image.dart';
export 'src/profile/data/base_public_profile_gateway.dart';
export 'src/profile/data/public_profile_gateway.dart';
export 'src/profile/data/public_profile_repository.dart';
export 'src/profile/model/public_profile.dart';
export 'src/profile/widget/halkyon_profile.dart';
export 'src/profile/widget/profile_sheet.dart';
export 'src/profile/widget/public_profile_section.dart';
export 'src/simplelogin/bloc/simplelogin_cubit.dart';
export 'src/simplelogin/bloc/simplelogin_standing_cubit.dart';
export 'src/simplelogin/data/serverpod_simplelogin_gateway.dart';

View file

@ -446,5 +446,73 @@
"assistantProblemNotAllowed": "Das können nur die, für die er freigegeben ist.",
"assistantProblemFailed": "Das hat nicht geklappt. Versuch es noch einmal.",
"whatsNewTitle": "Neuigkeiten",
"whatsNewVersion": "Version {version}"
"whatsNewVersion": "Version {version}",
"profileIdentified": "Dein Fortschritt wird in deinem Konto gespeichert.",
"profileProblemAvatarInvalid": "Dieses Bild lässt sich nicht verwenden: Wähle ein PNG-, JPEG-, WebP- oder GIF-Bild.",
"profileProblemBioTooLong": "Höchstens 500 Zeichen.",
"profileProblemDisplayNameTooLong": "Höchstens 30 Zeichen.",
"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.",
"profileProblemLinkInvalid": "Links beginnen mit https:// oder http://.",
"profileProblemPublished": "Du hast veröffentlicht: Dein Handle kann sich nicht mehr ändern und dein Profil nicht mehr gehen.",
"profileProblemTooManyHandleChecks": "Es wurden zu viele Handles ausprobiert. Versuche es in einer Stunde noch einmal.",
"profileProblemTooManyLinkChecks": "Die Links wurden zu oft geprüft. Versuche es in einer Stunde noch einmal.",
"profileProblemTooManyLinks": "Höchstens 4 Links.",
"profileSheetNotFound": "Niemand auf dieser Instanz hat diesen Handle.",
"profileSheetTitle": "Profil",
"profileSheetUnreachable": "Das Profil konnte nicht geladen werden. Prüfe deine Verbindung.",
"profileSignInWays": "Anmeldewege",
"profileTabProfile": "Profil",
"profileTitle": "Profil",
"profileWayEmail": "E-Mail-Adresse",
"profileWayLinked": "Meldet dich an",
"publicProfileAddLink": "Link hinzufügen",
"publicProfileAddress": "Andere finden dich als @{address}",
"publicProfileAddressPrivate": "Privat, bis du dein Profil ausfüllst oder etwas veröffentlichst; dann finden dich andere als @{address}.",
"publicProfileBio": "Über dich",
"publicProfileCheckLinks": "Links prüfen",
"publicProfileChoosePicture": "Bild wählen",
"publicProfileDisplayName": "Anzeigename",
"publicProfileDisplayNameHint": "Der Name neben deinem Handle",
"publicProfileFailed": "Die Instanz ist nicht erreichbar. Versuche es erneut.",
"publicProfileHandle": "Handle",
"publicProfileHandleChangeTitle": "Ändere dein Handle",
"publicProfileHandleChecking": "Wird geprüft …",
"publicProfileHandleChoose": "Wähle ein Handle",
"publicProfileHandleChooseTitle": "Wähle dein Handle",
"publicProfileHandleConfirmChange": "Ändern",
"publicProfileHandleConfirmChoose": "Wählen",
"publicProfileHandleDialogText": "Andere finden dich unter deinem Handle. Du kannst es ändern, bis du zum ersten Mal ein Lernset veröffentlichst.",
"publicProfileHandleFirst": "Wähle zuerst dein Handle: Damit wird dein Profil angelegt, und ab dann speichert es sich beim Schreiben.",
"publicProfileHandleFixed": "Fest, seit du veröffentlicht hast: Andere verweisen darauf.",
"publicProfileHandleFree": "@{handle} ist frei",
"publicProfileHandleHint": "Buchstaben, Ziffern und Unterstriche, bis zu 30",
"publicProfileHandleYours": "Das ist dein Handle",
"publicProfileIntro": "Wie andere dich sehen, sobald du veröffentlichst: das Handle, unter dem sie dich finden, dein Name, ein paar Worte und Links. Es speichert sich beim Schreiben, sobald du dein Handle gewählt hast.",
"publicProfileLinkHint": "https://…",
"publicProfileLinkVerified": "Verlinkt zurück auf dein Profil",
"publicProfileLinks": "Links",
"publicProfileLinksChecked": "{count, plural, =1{Geprüft: {verified} von 1 Link verlinkt zurück auf dein Profil.} other{Geprüft: {verified} von {count} Links verlinken zurück auf dein Profil.}}",
"publicProfileLinksHint": "Deine Website oder andere Konten, bis zu 4.",
"publicProfilePage": "Deine Profilseite: {url}",
"publicProfilePageCopied": "Adresse kopiert.",
"publicProfilePageCopy": "Adresse kopieren",
"publicProfilePicture": "Bild",
"publicProfilePictureLater": "Wähle dein Handle, um ein Bild hinzuzufügen.",
"publicProfilePictureType": "Bilder",
"publicProfileRemove": "Profil entfernen",
"publicProfileRemoveLink": "Link entfernen",
"publicProfileRemoveMessage": "Anzeigename, Text über dich, Links und Bild werden entfernt. Dein Handle bleibt deins und privat, bis du dein Profil wieder ausfüllst oder etwas veröffentlichst.",
"publicProfileRemovePicture": "Bild entfernen",
"publicProfileRemoveTitle": "Dein öffentliches Profil entfernen?",
"publicProfileRemoved": "Profil entfernt.",
"publicProfileRetry": "Erneut versuchen",
"publicProfileSavedNow": "Gespeichert",
"publicProfileSaving": "Wird gespeichert …",
"publicProfileTitle": "Öffentliches Profil",
"publicProfileUnreachable": "Dein öffentliches Profil ist gerade nicht erreichbar.",
"publicProfileVerifyHint": "Ein Link bekommt ein Häkchen, sobald seine Seite mit rel=\"me\" auf deine Profilseite zurückverlinkt, wie Mastodon Links prüft.",
"profileAccount": "Konto",
"profileSheetLinkVerified": "Verlinkt zurück auf dieses Profil"
}

View file

@ -1468,6 +1468,116 @@
"whatsNewVersion": "Version {version}",
"@whatsNewVersion": {
"description": "Above a release's changes in What's new.",
"placeholders": {"version": {"type": "String"}}
}
"placeholders": {
"version": {
"type": "String"
}
}
},
"profileIdentified": "Your progress is kept with your account.",
"profileProblemAvatarInvalid": "This picture can't be used: choose a PNG, JPEG, WebP or GIF image.",
"profileProblemBioTooLong": "At most 500 characters.",
"profileProblemDisplayNameTooLong": "At most 30 characters.",
"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.",
"profileProblemLinkInvalid": "Links start with https:// or http://.",
"profileProblemPublished": "You have published: your handle can't change, nor your profile go.",
"profileProblemTooManyHandleChecks": "Too many handles were tried. Try again in an hour.",
"profileProblemTooManyLinkChecks": "The links were checked too often. Try again in an hour.",
"profileProblemTooManyLinks": "At most 4 links.",
"profileSheetNotFound": "Nobody on this instance has this handle.",
"profileSheetTitle": "Profile",
"profileSheetUnreachable": "The profile couldn’t be loaded. Check your connection.",
"profileSignInWays": "Ways to sign in",
"profileTabProfile": "Profile",
"profileTitle": "Profile",
"profileWayEmail": "Email address",
"profileWayLinked": "Signs you in",
"publicProfileAddLink": "Add link",
"publicProfileAddress": "Others find you as @{address}",
"@publicProfileAddress": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"publicProfileAddressPrivate": "Private until you fill in your profile or publish something; then others find you as @{address}.",
"@publicProfileAddressPrivate": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"publicProfileBio": "Bio",
"publicProfileCheckLinks": "Check links",
"publicProfileChoosePicture": "Choose picture",
"publicProfileDisplayName": "Display name",
"publicProfileDisplayNameHint": "The name shown beside your handle",
"publicProfileFailed": "Couldn't reach the instance. Try again.",
"publicProfileHandle": "Handle",
"publicProfileHandleChangeTitle": "Change your handle",
"publicProfileHandleChecking": "Checking…",
"publicProfileHandleChoose": "Choose a handle",
"publicProfileHandleChooseTitle": "Choose your handle",
"publicProfileHandleConfirmChange": "Change",
"publicProfileHandleConfirmChoose": "Choose",
"publicProfileHandleDialogText": "Others find you by your handle. You can change it until you first publish a studyset.",
"publicProfileHandleFirst": "Choose your handle first: your profile is made with it, and saves as you write from then on.",
"publicProfileHandleFixed": "Fixed since you published: others refer to it.",
"publicProfileHandleFree": "@{handle} is free",
"@publicProfileHandleFree": {
"placeholders": {
"handle": {
"type": "String"
}
}
},
"publicProfileHandleHint": "Letters, digits and underscores, up to 30",
"publicProfileHandleYours": "This is your handle",
"publicProfileIntro": "How others see you once you publish: the handle they find you by, your name, a few words and links. It saves as you write, once you have chosen your handle.",
"publicProfileLinkHint": "https://…",
"publicProfileLinkVerified": "Links back to your profile",
"publicProfileLinks": "Links",
"publicProfileLinksChecked": "{count, plural, =1{Checked: {verified} of 1 link links back to your profile.} other{Checked: {verified} of {count} links link back to your profile.}}",
"@publicProfileLinksChecked": {
"placeholders": {
"count": {
"type": "int"
},
"verified": {
"type": "int"
}
}
},
"publicProfileLinksHint": "Your site or other accounts, up to 4.",
"publicProfilePage": "Your profile page: {url}",
"@publicProfilePage": {
"placeholders": {
"url": {
"type": "String"
}
}
},
"publicProfilePageCopied": "Address copied.",
"publicProfilePageCopy": "Copy the address",
"publicProfilePicture": "Picture",
"publicProfilePictureLater": "Choose your handle to add a picture.",
"publicProfilePictureType": "Images",
"publicProfileRemove": "Remove profile",
"publicProfileRemoveLink": "Remove link",
"publicProfileRemoveMessage": "Your display name, bio, links and picture are removed. Your handle stays yours and private until you fill in your profile again or publish something.",
"publicProfileRemovePicture": "Remove picture",
"publicProfileRemoveTitle": "Remove your public profile?",
"publicProfileRemoved": "Profile removed.",
"publicProfileRetry": "Retry",
"publicProfileSavedNow": "Saved",
"publicProfileSaving": "Saving…",
"publicProfileTitle": "Public profile",
"publicProfileUnreachable": "Your public profile can't be reached right now.",
"publicProfileVerifyHint": "A link gets a checkmark once its page links back to your profile page with rel=\"me\", as Mastodon checks links.",
"profileAccount": "Account",
"profileSheetLinkVerified": "Links back to this profile"
}

View file

@ -2784,6 +2784,414 @@ abstract class HalkyonAppLocalizations {
/// In en, this message translates to:
/// **'Version {version}'**
String whatsNewVersion(String version);
/// No description provided for @profileIdentified.
///
/// In en, this message translates to:
/// **'Your progress is kept with your account.'**
String get profileIdentified;
/// 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 @profileProblemBioTooLong.
///
/// In en, this message translates to:
/// **'At most 500 characters.'**
String get profileProblemBioTooLong;
/// No description provided for @profileProblemDisplayNameTooLong.
///
/// In en, this message translates to:
/// **'At most 30 characters.'**
String get profileProblemDisplayNameTooLong;
/// 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 @profileProblemLinkInvalid.
///
/// In en, this message translates to:
/// **'Links start with https:// or http://.'**
String get profileProblemLinkInvalid;
/// 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 @profileProblemTooManyHandleChecks.
///
/// In en, this message translates to:
/// **'Too many handles were tried. Try again in an hour.'**
String get profileProblemTooManyHandleChecks;
/// No description provided for @profileProblemTooManyLinkChecks.
///
/// In en, this message translates to:
/// **'The links were checked too often. Try again in an hour.'**
String get profileProblemTooManyLinkChecks;
/// No description provided for @profileProblemTooManyLinks.
///
/// In en, this message translates to:
/// **'At most 4 links.'**
String get profileProblemTooManyLinks;
/// No description provided for @profileSheetNotFound.
///
/// In en, this message translates to:
/// **'Nobody on this instance has this handle.'**
String get profileSheetNotFound;
/// No description provided for @profileSheetTitle.
///
/// In en, this message translates to:
/// **'Profile'**
String get profileSheetTitle;
/// No description provided for @profileSheetUnreachable.
///
/// In en, this message translates to:
/// **'The profile couldn’t be loaded. Check your connection.'**
String get profileSheetUnreachable;
/// No description provided for @profileSignInWays.
///
/// In en, this message translates to:
/// **'Ways to sign in'**
String get profileSignInWays;
/// No description provided for @profileTabProfile.
///
/// In en, this message translates to:
/// **'Profile'**
String get profileTabProfile;
/// No description provided for @profileTitle.
///
/// In en, this message translates to:
/// **'Profile'**
String get profileTitle;
/// No description provided for @profileWayEmail.
///
/// In en, this message translates to:
/// **'Email address'**
String get profileWayEmail;
/// No description provided for @profileWayLinked.
///
/// In en, this message translates to:
/// **'Signs you in'**
String get profileWayLinked;
/// No description provided for @publicProfileAddLink.
///
/// In en, this message translates to:
/// **'Add link'**
String get publicProfileAddLink;
/// No description provided for @publicProfileAddress.
///
/// In en, this message translates to:
/// **'Others find you as @{address}'**
String publicProfileAddress(String address);
/// No description provided for @publicProfileAddressPrivate.
///
/// In en, this message translates to:
/// **'Private until you fill in your profile or publish something; then others find you as @{address}.'**
String publicProfileAddressPrivate(String address);
/// No description provided for @publicProfileBio.
///
/// In en, this message translates to:
/// **'Bio'**
String get publicProfileBio;
/// No description provided for @publicProfileCheckLinks.
///
/// In en, this message translates to:
/// **'Check links'**
String get publicProfileCheckLinks;
/// No description provided for @publicProfileChoosePicture.
///
/// In en, this message translates to:
/// **'Choose picture'**
String get publicProfileChoosePicture;
/// 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 @publicProfileFailed.
///
/// In en, this message translates to:
/// **'Couldn\'t reach the instance. Try again.'**
String get publicProfileFailed;
/// No description provided for @publicProfileHandle.
///
/// In en, this message translates to:
/// **'Handle'**
String get publicProfileHandle;
/// No description provided for @publicProfileHandleChangeTitle.
///
/// In en, this message translates to:
/// **'Change your handle'**
String get publicProfileHandleChangeTitle;
/// No description provided for @publicProfileHandleChecking.
///
/// In en, this message translates to:
/// **'Checking…'**
String get publicProfileHandleChecking;
/// No description provided for @publicProfileHandleChoose.
///
/// In en, this message translates to:
/// **'Choose a handle'**
String get publicProfileHandleChoose;
/// No description provided for @publicProfileHandleChooseTitle.
///
/// In en, this message translates to:
/// **'Choose your handle'**
String get publicProfileHandleChooseTitle;
/// No description provided for @publicProfileHandleConfirmChange.
///
/// In en, this message translates to:
/// **'Change'**
String get publicProfileHandleConfirmChange;
/// No description provided for @publicProfileHandleConfirmChoose.
///
/// In en, this message translates to:
/// **'Choose'**
String get publicProfileHandleConfirmChoose;
/// No description provided for @publicProfileHandleDialogText.
///
/// In en, this message translates to:
/// **'Others find you by your handle. You can change it until you first publish a studyset.'**
String get publicProfileHandleDialogText;
/// No description provided for @publicProfileHandleFirst.
///
/// In en, this message translates to:
/// **'Choose your handle first: your profile is made with it, and saves as you write from then on.'**
String get publicProfileHandleFirst;
/// 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 @publicProfileHandleFree.
///
/// In en, this message translates to:
/// **'@{handle} is free'**
String publicProfileHandleFree(String handle);
/// 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 @publicProfileHandleYours.
///
/// In en, this message translates to:
/// **'This is your handle'**
String get publicProfileHandleYours;
/// 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. It saves as you write, once you have chosen your handle.'**
String get publicProfileIntro;
/// No description provided for @publicProfileLinkHint.
///
/// In en, this message translates to:
/// **'https://…'**
String get publicProfileLinkHint;
/// No description provided for @publicProfileLinkVerified.
///
/// In en, this message translates to:
/// **'Links back to your profile'**
String get publicProfileLinkVerified;
/// No description provided for @publicProfileLinks.
///
/// In en, this message translates to:
/// **'Links'**
String get publicProfileLinks;
/// No description provided for @publicProfileLinksChecked.
///
/// In en, this message translates to:
/// **'{count, plural, =1{Checked: {verified} of 1 link links back to your profile.} other{Checked: {verified} of {count} links link back to your profile.}}'**
String publicProfileLinksChecked(int count, int verified);
/// 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 @publicProfilePage.
///
/// In en, this message translates to:
/// **'Your profile page: {url}'**
String publicProfilePage(String url);
/// No description provided for @publicProfilePageCopied.
///
/// In en, this message translates to:
/// **'Address copied.'**
String get publicProfilePageCopied;
/// No description provided for @publicProfilePageCopy.
///
/// In en, this message translates to:
/// **'Copy the address'**
String get publicProfilePageCopy;
/// No description provided for @publicProfilePicture.
///
/// In en, this message translates to:
/// **'Picture'**
String get publicProfilePicture;
/// No description provided for @publicProfilePictureLater.
///
/// In en, this message translates to:
/// **'Choose your handle to add a picture.'**
String get publicProfilePictureLater;
/// No description provided for @publicProfilePictureType.
///
/// In en, this message translates to:
/// **'Images'**
String get publicProfilePictureType;
/// No description provided for @publicProfileRemove.
///
/// In en, this message translates to:
/// **'Remove profile'**
String get publicProfileRemove;
/// No description provided for @publicProfileRemoveLink.
///
/// In en, this message translates to:
/// **'Remove link'**
String get publicProfileRemoveLink;
/// No description provided for @publicProfileRemoveMessage.
///
/// In en, this message translates to:
/// **'Your display name, bio, links and picture are removed. Your handle stays yours and private until you fill in your profile again or publish something.'**
String get publicProfileRemoveMessage;
/// No description provided for @publicProfileRemovePicture.
///
/// In en, this message translates to:
/// **'Remove picture'**
String get publicProfileRemovePicture;
/// No description provided for @publicProfileRemoveTitle.
///
/// In en, this message translates to:
/// **'Remove your public profile?'**
String get publicProfileRemoveTitle;
/// No description provided for @publicProfileRemoved.
///
/// In en, this message translates to:
/// **'Profile removed.'**
String get publicProfileRemoved;
/// No description provided for @publicProfileRetry.
///
/// In en, this message translates to:
/// **'Retry'**
String get publicProfileRetry;
/// No description provided for @publicProfileSavedNow.
///
/// In en, this message translates to:
/// **'Saved'**
String get publicProfileSavedNow;
/// No description provided for @publicProfileSaving.
///
/// In en, this message translates to:
/// **'Saving…'**
String get publicProfileSaving;
/// No description provided for @publicProfileTitle.
///
/// In en, this message translates to:
/// **'Public profile'**
String get publicProfileTitle;
/// 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 @publicProfileVerifyHint.
///
/// In en, this message translates to:
/// **'A link gets a checkmark once its page links back to your profile page with rel=\"me\", as Mastodon checks links.'**
String get publicProfileVerifyHint;
/// No description provided for @profileAccount.
///
/// In en, this message translates to:
/// **'Account'**
String get profileAccount;
/// No description provided for @profileSheetLinkVerified.
///
/// In en, this message translates to:
/// **'Links back to this profile'**
String get profileSheetLinkVerified;
}
class _HalkyonAppLocalizationsDelegate

View file

@ -1764,4 +1764,247 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
String whatsNewVersion(String version) {
return 'Version $version';
}
@override
String get profileIdentified =>
'Dein Fortschritt wird in deinem Konto gespeichert.';
@override
String get profileProblemAvatarInvalid =>
'Dieses Bild lässt sich nicht verwenden: Wähle ein PNG-, JPEG-, WebP- oder GIF-Bild.';
@override
String get profileProblemBioTooLong => 'Höchstens 500 Zeichen.';
@override
String get profileProblemDisplayNameTooLong => 'Höchstens 30 Zeichen.';
@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 profileProblemLinkInvalid =>
'Links beginnen mit https:// oder http://.';
@override
String get profileProblemPublished =>
'Du hast veröffentlicht: Dein Handle kann sich nicht mehr ändern und dein Profil nicht mehr gehen.';
@override
String get profileProblemTooManyHandleChecks =>
'Es wurden zu viele Handles ausprobiert. Versuche es in einer Stunde noch einmal.';
@override
String get profileProblemTooManyLinkChecks =>
'Die Links wurden zu oft geprüft. Versuche es in einer Stunde noch einmal.';
@override
String get profileProblemTooManyLinks => 'Höchstens 4 Links.';
@override
String get profileSheetNotFound =>
'Niemand auf dieser Instanz hat diesen Handle.';
@override
String get profileSheetTitle => 'Profil';
@override
String get profileSheetUnreachable =>
'Das Profil konnte nicht geladen werden. Prüfe deine Verbindung.';
@override
String get profileSignInWays => 'Anmeldewege';
@override
String get profileTabProfile => 'Profil';
@override
String get profileTitle => 'Profil';
@override
String get profileWayEmail => 'E-Mail-Adresse';
@override
String get profileWayLinked => 'Meldet dich an';
@override
String get publicProfileAddLink => 'Link hinzufügen';
@override
String publicProfileAddress(String address) {
return 'Andere finden dich als @$address';
}
@override
String publicProfileAddressPrivate(String address) {
return 'Privat, bis du dein Profil ausfüllst oder etwas veröffentlichst; dann finden dich andere als @$address.';
}
@override
String get publicProfileBio => 'Über dich';
@override
String get publicProfileCheckLinks => 'Links prüfen';
@override
String get publicProfileChoosePicture => 'Bild wählen';
@override
String get publicProfileDisplayName => 'Anzeigename';
@override
String get publicProfileDisplayNameHint => 'Der Name neben deinem Handle';
@override
String get publicProfileFailed =>
'Die Instanz ist nicht erreichbar. Versuche es erneut.';
@override
String get publicProfileHandle => 'Handle';
@override
String get publicProfileHandleChangeTitle => 'Ändere dein Handle';
@override
String get publicProfileHandleChecking => 'Wird geprüft …';
@override
String get publicProfileHandleChoose => 'Wähle ein Handle';
@override
String get publicProfileHandleChooseTitle => 'Wähle dein Handle';
@override
String get publicProfileHandleConfirmChange => 'Ändern';
@override
String get publicProfileHandleConfirmChoose => 'Wählen';
@override
String get publicProfileHandleDialogText =>
'Andere finden dich unter deinem Handle. Du kannst es ändern, bis du zum ersten Mal ein Lernset veröffentlichst.';
@override
String get publicProfileHandleFirst =>
'Wähle zuerst dein Handle: Damit wird dein Profil angelegt, und ab dann speichert es sich beim Schreiben.';
@override
String get publicProfileHandleFixed =>
'Fest, seit du veröffentlicht hast: Andere verweisen darauf.';
@override
String publicProfileHandleFree(String handle) {
return '@$handle ist frei';
}
@override
String get publicProfileHandleHint =>
'Buchstaben, Ziffern und Unterstriche, bis zu 30';
@override
String get publicProfileHandleYours => 'Das ist dein Handle';
@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. Es speichert sich beim Schreiben, sobald du dein Handle gewählt hast.';
@override
String get publicProfileLinkHint => 'https://…';
@override
String get publicProfileLinkVerified => 'Verlinkt zurück auf dein Profil';
@override
String get publicProfileLinks => 'Links';
@override
String publicProfileLinksChecked(int count, int verified) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other:
'Geprüft: $verified von $count Links verlinken zurück auf dein Profil.',
one: 'Geprüft: $verified von 1 Link verlinkt zurück auf dein Profil.',
);
return '$_temp0';
}
@override
String get publicProfileLinksHint =>
'Deine Website oder andere Konten, bis zu 4.';
@override
String publicProfilePage(String url) {
return 'Deine Profilseite: $url';
}
@override
String get publicProfilePageCopied => 'Adresse kopiert.';
@override
String get publicProfilePageCopy => 'Adresse kopieren';
@override
String get publicProfilePicture => 'Bild';
@override
String get publicProfilePictureLater =>
'Wähle dein Handle, um ein Bild hinzuzufügen.';
@override
String get publicProfilePictureType => 'Bilder';
@override
String get publicProfileRemove => 'Profil entfernen';
@override
String get publicProfileRemoveLink => 'Link entfernen';
@override
String get publicProfileRemoveMessage =>
'Anzeigename, Text über dich, Links und Bild werden entfernt. Dein Handle bleibt deins und privat, bis du dein Profil wieder ausfüllst oder etwas veröffentlichst.';
@override
String get publicProfileRemovePicture => 'Bild entfernen';
@override
String get publicProfileRemoveTitle => 'Dein öffentliches Profil entfernen?';
@override
String get publicProfileRemoved => 'Profil entfernt.';
@override
String get publicProfileRetry => 'Erneut versuchen';
@override
String get publicProfileSavedNow => 'Gespeichert';
@override
String get publicProfileSaving => 'Wird gespeichert …';
@override
String get publicProfileTitle => 'Öffentliches Profil';
@override
String get publicProfileUnreachable =>
'Dein öffentliches Profil ist gerade nicht erreichbar.';
@override
String get publicProfileVerifyHint =>
'Ein Link bekommt ein Häkchen, sobald seine Seite mit rel=\"me\" auf deine Profilseite zurückverlinkt, wie Mastodon Links prüft.';
@override
String get profileAccount => 'Konto';
@override
String get profileSheetLinkVerified => 'Verlinkt zurück auf dieses Profil';
}

View file

@ -1737,6 +1737,244 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
String whatsNewVersion(String version) {
return 'Version $version';
}
@override
String get profileIdentified => 'Your progress is kept with your account.';
@override
String get profileProblemAvatarInvalid =>
'This picture can\'t be used: choose a PNG, JPEG, WebP or GIF image.';
@override
String get profileProblemBioTooLong => 'At most 500 characters.';
@override
String get profileProblemDisplayNameTooLong => 'At most 30 characters.';
@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 profileProblemLinkInvalid =>
'Links start with https:// or http://.';
@override
String get profileProblemPublished =>
'You have published: your handle can\'t change, nor your profile go.';
@override
String get profileProblemTooManyHandleChecks =>
'Too many handles were tried. Try again in an hour.';
@override
String get profileProblemTooManyLinkChecks =>
'The links were checked too often. Try again in an hour.';
@override
String get profileProblemTooManyLinks => 'At most 4 links.';
@override
String get profileSheetNotFound => 'Nobody on this instance has this handle.';
@override
String get profileSheetTitle => 'Profile';
@override
String get profileSheetUnreachable =>
'The profile couldn’t be loaded. Check your connection.';
@override
String get profileSignInWays => 'Ways to sign in';
@override
String get profileTabProfile => 'Profile';
@override
String get profileTitle => 'Profile';
@override
String get profileWayEmail => 'Email address';
@override
String get profileWayLinked => 'Signs you in';
@override
String get publicProfileAddLink => 'Add link';
@override
String publicProfileAddress(String address) {
return 'Others find you as @$address';
}
@override
String publicProfileAddressPrivate(String address) {
return 'Private until you fill in your profile or publish something; then others find you as @$address.';
}
@override
String get publicProfileBio => 'Bio';
@override
String get publicProfileCheckLinks => 'Check links';
@override
String get publicProfileChoosePicture => 'Choose picture';
@override
String get publicProfileDisplayName => 'Display name';
@override
String get publicProfileDisplayNameHint =>
'The name shown beside your handle';
@override
String get publicProfileFailed => 'Couldn\'t reach the instance. Try again.';
@override
String get publicProfileHandle => 'Handle';
@override
String get publicProfileHandleChangeTitle => 'Change your handle';
@override
String get publicProfileHandleChecking => 'Checking…';
@override
String get publicProfileHandleChoose => 'Choose a handle';
@override
String get publicProfileHandleChooseTitle => 'Choose your handle';
@override
String get publicProfileHandleConfirmChange => 'Change';
@override
String get publicProfileHandleConfirmChoose => 'Choose';
@override
String get publicProfileHandleDialogText =>
'Others find you by your handle. You can change it until you first publish a studyset.';
@override
String get publicProfileHandleFirst =>
'Choose your handle first: your profile is made with it, and saves as you write from then on.';
@override
String get publicProfileHandleFixed =>
'Fixed since you published: others refer to it.';
@override
String publicProfileHandleFree(String handle) {
return '@$handle is free';
}
@override
String get publicProfileHandleHint =>
'Letters, digits and underscores, up to 30';
@override
String get publicProfileHandleYours => 'This is your handle';
@override
String get publicProfileIntro =>
'How others see you once you publish: the handle they find you by, your name, a few words and links. It saves as you write, once you have chosen your handle.';
@override
String get publicProfileLinkHint => 'https://…';
@override
String get publicProfileLinkVerified => 'Links back to your profile';
@override
String get publicProfileLinks => 'Links';
@override
String publicProfileLinksChecked(int count, int verified) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Checked: $verified of $count links link back to your profile.',
one: 'Checked: $verified of 1 link links back to your profile.',
);
return '$_temp0';
}
@override
String get publicProfileLinksHint => 'Your site or other accounts, up to 4.';
@override
String publicProfilePage(String url) {
return 'Your profile page: $url';
}
@override
String get publicProfilePageCopied => 'Address copied.';
@override
String get publicProfilePageCopy => 'Copy the address';
@override
String get publicProfilePicture => 'Picture';
@override
String get publicProfilePictureLater =>
'Choose your handle to add a picture.';
@override
String get publicProfilePictureType => 'Images';
@override
String get publicProfileRemove => 'Remove profile';
@override
String get publicProfileRemoveLink => 'Remove link';
@override
String get publicProfileRemoveMessage =>
'Your display name, bio, links and picture are removed. Your handle stays yours and private until you fill in your profile again or publish something.';
@override
String get publicProfileRemovePicture => 'Remove picture';
@override
String get publicProfileRemoveTitle => 'Remove your public profile?';
@override
String get publicProfileRemoved => 'Profile removed.';
@override
String get publicProfileRetry => 'Retry';
@override
String get publicProfileSavedNow => 'Saved';
@override
String get publicProfileSaving => 'Saving…';
@override
String get publicProfileTitle => 'Public profile';
@override
String get publicProfileUnreachable =>
'Your public profile can\'t be reached right now.';
@override
String get publicProfileVerifyHint =>
'A link gets a checkmark once its page links back to your profile page with rel=\"me\", as Mastodon checks links.';
@override
String get profileAccount => 'Account';
@override
String get profileSheetLinkVerified => 'Links back to this profile';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -2,9 +2,11 @@ import 'package:flutter/widgets.dart';
import 'package:halkyon_app/src/assistant/data/assistant_repository.dart';
import 'package:halkyon_app/src/auth/data/auth_repository.dart';
import 'package:halkyon_app/src/auth/model/auth_status.dart';
import 'package:halkyon_app/src/files/file_picker.dart';
import 'package:halkyon_app/src/files/file_saver.dart';
import 'package:halkyon_app/src/operator/data/instance_operator_repository.dart';
import 'package:halkyon_app/src/password/data/password_repository.dart';
import 'package:halkyon_app/src/profile/data/public_profile_repository.dart';
import 'package:halkyon_app/src/simplelogin/data/simplelogin_repository.dart';
/// What can be closed once it is no longer needed: an app's dependencies on
@ -54,6 +56,15 @@ abstract interface class AppDependencies<P extends Disposable>()
/// recovery codes, an app's exports).
FileSaver get fileSaver;
/// Lets the person pick a file of the device (a profile's picture, an
/// app's imports).
FilePicker get files;
/// People's public profiles on the instance, the signed-in person's own
/// among them: one for every Halkyon app, kept by halkyon_base's register
/// (ADR 0016; one Profile for every app, decided 2026-11-02).
PublicProfileRepository get profiles;
/// Where the source of what the instance runs is, as it names it (its
/// basics, halkyon_base's): both licences require offering it to the
/// people who use the instance, which About does (`showAppAbout`).

View file

@ -0,0 +1,62 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:file_selector/file_selector.dart';
/// A file the person picked: its name and its content.
typedef PickedFile = ({String name, Uint8List bytes});
/// Lets the person pick a file of this device.
abstract interface class FilePicker() {
/// The file the person picked, of a type [label] names with [extensions];
/// null if they picked none.
Future<PickedFile?> pick({
required String label,
required List<String> extensions,
});
/// The files the person picked, of a type [label] names with
/// [extensions]; none if they picked none.
Future<List<PickedFile>> pickMany({
required String label,
required List<String> extensions,
});
}
/// [FilePicker] with the platform's own dialog (`file_selector`).
class FileSelectorFilePicker() implements FilePicker {
@override
Future<PickedFile?> pick({
required String label,
required List<String> extensions,
}) async {
final file = await openFile(
acceptedTypeGroups: _typeGroups(label, extensions),
);
return file == null
? null
: (name: file.name, bytes: await file.readAsBytes());
}
@override
Future<List<PickedFile>> pickMany({
required String label,
required List<String> extensions,
}) async => [
for (final file in await openFiles(
acceptedTypeGroups: _typeGroups(label, extensions),
))
(name: file.name, bytes: await file.readAsBytes()),
];
List<XTypeGroup> _typeGroups(String label, List<String> extensions) => [
// Android filters by media type, which extensions like Anki's lack, so
// it offers every file there; what isn't one is refused later.
if (!Platform.isAndroid)
XTypeGroup(
label: label,
extensions: extensions,
uniformTypeIdentifiers: const ['public.data'],
),
];
}

View file

@ -0,0 +1,98 @@
import 'dart:async';
import 'dart:developer';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/src/profile/data/public_profile_repository.dart';
import 'package:halkyon_app/src/profile/model/public_profile.dart';
/// Whether the handle being typed could be the person's.
enum ProfileHandleCheck() {
/// Nothing typed, or the person's own handle: nothing to say.
none,
/// Being asked of the instance.
checking,
/// Free.
free,
/// Refused: [ProfileHandleState.problem] says why.
refused,
/// The instance couldn't be asked; saving will say.
unknown,
}
/// The handle typed, and what the instance says of it.
final class const ProfileHandleState({
final String handle = '',
final ProfileHandleCheck check = ProfileHandleCheck.none,
final ProfileProblem? problem,
}) with Equatable {
@override
List<Object?> get props => [handle, check, problem];
}
/// Asks the instance whether a handle could be the person's while it is
/// typed (decided 2026-10-12), once typing pauses, and says only what it
/// says of the handle typed last. The person's own handle needs
/// no asking.
class ProfileHandleCubit(
final PublicProfileRepository _profiles, {
final String? _current,
}) extends Cubit<ProfileHandleState> {
this : super(ProfileHandleState(handle: _current ?? ''));
/// How long typing must pause before the instance is asked.
static const pause = Duration(milliseconds: 300);
Timer? _pause;
/// The person typed [handle].
void typed(String handle) {
_pause?.cancel();
final trimmed = handle.trim();
if (trimmed.isEmpty || trimmed == _current) {
emit(ProfileHandleState(handle: trimmed));
return;
}
emit(
ProfileHandleState(handle: trimmed, check: ProfileHandleCheck.checking),
);
_pause = Timer(pause, () => unawaited(_ask(trimmed)));
}
Future<void> _ask(String handle) async {
ProfileHandleState answer;
try {
answer = switch (await _profiles.checkHandle(handle)) {
null => ProfileHandleState(
handle: handle,
check: ProfileHandleCheck.free,
),
final problem => ProfileHandleState(
handle: handle,
check: ProfileHandleCheck.refused,
problem: problem,
),
};
} on Object catch (error) {
log('Checking a handle failed', name: 'Profile', error: error);
answer = ProfileHandleState(
handle: handle,
check: ProfileHandleCheck.unknown,
);
}
// Only what is still typed.
if (!isClosed && state.handle == handle) {
emit(answer);
}
}
@override
Future<void> close() {
_pause?.cancel();
return super.close();
}
}

View file

@ -0,0 +1,325 @@
import 'dart:developer';
import 'dart:typed_data';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/src/profile/data/public_profile_repository.dart';
import 'package:halkyon_app/src/profile/model/public_profile.dart';
/// The person's public profile: reading it, saving it as it is written
/// (field by field, decided 2026-10-12), removing it, and having its links
/// checked (ADR 0011): after a save that brought a link not yet checked, and
/// when asked. A save asked for while
/// another is on its way follows it, the latest of those waiting, so none
/// is lost and none overtakes another. A handle chosen holds for every save
/// after it until one with it went through or was refused for it, so a
/// field's save can't take a new handle back.
class PublicProfileBloc(final PublicProfileRepository _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),
);
on<PublicProfileEventLinksChecked>(_onLinksChecked);
}
/// The links checked by themselves already, or asked for.
final _checkedLinks = <String>{};
Future<void> _onLinksChecked(
PublicProfileEventLinksChecked event,
Emitter<PublicProfileState> emit,
) async {
if (state case PublicProfileStateReady(
:final profile?,
saving: false,
checkingLinks: false,
)) {
_checkedLinks.addAll(profile.links);
emit(PublicProfileStateReady(profile: profile, checkingLinks: true));
try {
final checked = await _profiles.checkLinks();
switch (state) {
case PublicProfileStateReady(checkingLinks: true):
emit(
PublicProfileStateReady(
profile: checked,
outcome: event.asked ? PublicProfileOutcome.linksChecked : null,
),
);
// Saved meanwhile: only the checks are new.
case PublicProfileStateReady(
profile: final current?,
:final saving,
:final problem,
:final outcome,
):
emit(
PublicProfileStateReady(
profile: current.withVerifiedLinks(checked.verifiedLinks),
saving: saving,
problem: problem,
outcome: outcome,
),
);
case _:
}
} on ProfileRefused catch (refused) {
emit(
PublicProfileStateReady(profile: profile, problem: refused.problem),
);
} on Object catch (error) {
log('Checking the links failed', name: 'Profile', error: error);
emit(
PublicProfileStateReady(
profile: profile,
outcome: event.asked ? PublicProfileOutcome.failed : null,
),
);
}
}
}
/// 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());
}
}
/// The save asked for while another was on its way, the latest.
PublicProfileEventSaved? _waiting;
/// The handle chosen last, until a save with it went through or was
/// refused for it.
String? _chosenHandle;
Future<void> _onSaved(
PublicProfileEventSaved event,
Emitter<PublicProfileState> emit,
) async {
if (event.handle case final handle?) {
_chosenHandle = handle;
}
if (state case PublicProfileStateReady(saving: true)) {
_waiting = event;
return;
}
PublicProfileEventSaved? next = event;
while (next != null) {
if (state case PublicProfileStateReady(:final profile, saving: false)) {
await _save(next, profile, emit);
}
next = _waiting;
_waiting = null;
}
}
Future<void> _save(
PublicProfileEventSaved event,
PublicProfile? profile,
Emitter<PublicProfileState> emit,
) async {
// No profile is made without a handle.
final handle = _chosenHandle ?? profile?.handle;
if (handle == null) {
return;
}
emit(PublicProfileStateReady(profile: profile, saving: true));
try {
final saved = await _profiles.save(
handle: handle,
displayName: event.displayName,
bio: event.bio,
links: event.links,
);
if (saved.handle.toLowerCase() == _chosenHandle?.toLowerCase()) {
_chosenHandle = null;
}
emit(
PublicProfileStateReady(
profile: saved,
outcome: PublicProfileOutcome.saved,
),
);
// A link new or changed is checked by itself.
if (saved.links.any(
(link) =>
!saved.verifiedLinks.contains(link) &&
!_checkedLinks.contains(link),
)) {
add(const PublicProfileEventLinksChecked(asked: false));
}
} on ProfileRefused catch (refused) {
if (handle == _chosenHandle &&
ProfileProblem.handleProblems.contains(refused.problem)) {
_chosenHandle = null;
}
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 {
final left = await _profiles.remove();
emit(
PublicProfileStateReady(
profile: left,
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,
/// The links were checked, as asked.
linksChecked,
}
/// 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, whether its links are being checked, why the last change 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 bool checkingLinks = false,
final ProfileProblem? problem,
final PublicProfileOutcome? outcome,
}) extends PublicProfileState {
@override
List<Object?> get props => [profile, saving, checkingLinks, 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: a field they left, or a
/// [handle] they chose (null keeps the one chosen last, or the profile's).
final class const PublicProfileEventSaved({
required final String displayName,
required final String bio,
required final List<String> links,
final String? handle,
}) 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;
/// Check which links' pages link back to the profile page: [asked] by the
/// person, or by itself after a save that brought a link not yet checked.
final class const PublicProfileEventLinksChecked({final bool asked = true})
extends PublicProfileEvent {
@override
List<Object?> get props => [asked];
}

View file

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

View file

@ -0,0 +1,100 @@
import 'dart:typed_data';
import 'package:halkyon_app/src/profile/data/public_profile_gateway.dart';
import 'package:halkyon_app/src/profile/model/public_profile.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart' as base;
/// [PublicProfileGateway] asking the instance's halkyon_base, whose register
/// keeps people's profiles (ADR 0016): one profile for every Halkyon app
/// (decided 2026-11-02), asked through the module's endpoints on the app's
/// client, [_base].
class BasePublicProfileGateway(final base.Caller _base)
implements PublicProfileGateway {
/// The caller's own profile's endpoint.
base.EndpointProfile get _profiles => _base.profile;
@override
Future<PublicProfile?> mine() async => switch (await _profiles.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 _profiles.save(handle, displayName, bio, links),
),
);
@override
Future<PublicProfile?> byHandle(String handle) async {
try {
return _profile(await _base.profiles.byHandle(handle));
} on base.ProfileNotFoundException {
return null;
}
}
@override
Future<ProfileProblem?> checkHandle(String handle) async =>
switch (await _profiles.checkHandle(handle)) {
null => null,
// A reason this app doesn't know reads as an invalid handle.
final reason =>
ProfileProblem.values.asNameMap()[reason.name] ??
ProfileProblem.handleInvalid,
};
@override
Future<PublicProfile?> remove() => _refusing(
() async => switch (await _profiles.remove()) {
final profile? => _profile(profile),
null => null,
},
);
@override
Future<PublicProfile> setAvatar(Uint8List png) => _refusing(
() async => _profile(
await _profiles.setAvatar(ByteData.sublistView(png)),
),
);
@override
Future<PublicProfile> removeAvatar() =>
_refusing(() async => _profile(await _profiles.removeAvatar()));
@override
Future<PublicProfile> checkLinks() =>
_refusing(() async => _profile(await _profiles.checkLinks()));
/// [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 base.ProfileRefusedException catch (error) {
throw ProfileRefused(
ProfileProblem.values.asNameMap()[error.reason.name] ??
ProfileProblem.handleInvalid,
);
}
}
PublicProfile _profile(base.Profile profile) => PublicProfile(
handle: profile.handle,
address: profile.address,
displayName: profile.displayName,
bio: profile.bio,
links: profile.links,
verifiedLinks: profile.verifiedLinks ?? const [],
pageUrl: profile.pageUrl,
avatarUrl: profile.avatarUrl,
handleFixed: profile.handleFixed,
);
}

View file

@ -0,0 +1,44 @@
import 'dart:typed_data';
import 'package:halkyon_app/src/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 PublicProfileGateway() {
/// The person's public profile; null while they have none.
Future<PublicProfile?> mine();
/// The public profile of whoever has [handle] on the instance; null
/// when nobody has.
Future<PublicProfile?> byHandle(String handle);
/// 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,
});
/// Why [handle] couldn't be the person's, as saving would refuse it; null
/// when it can.
Future<ProfileProblem?> checkHandle(String handle);
/// Removes what the person's public profile shows, making it private
/// again with its handle (ADR 0020, D27); returns what is left, null while
/// they have none. Throws [ProfileRefused] once they have published.
Future<PublicProfile?> 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();
/// Has the instance check which of the person's links' pages link back to
/// their profile page; returns the profile with the answer. Throws
/// [ProfileRefused] when it was asked too often.
Future<PublicProfile> checkLinks();
}

View file

@ -0,0 +1,72 @@
import 'dart:typed_data';
import 'package:halkyon_app/src/profile/data/avatar_image.dart';
import 'package:halkyon_app/src/profile/data/public_profile_gateway.dart';
import 'package:halkyon_app/src/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 PublicProfileRepository(
final PublicProfileGateway _gateway, {
final Future<Uint8List> Function(Uint8List picture) _drawAvatar = avatarPng,
}) {
/// The person's public profile; null while they have none.
Future<PublicProfile?> mine() => _gateway.mine();
/// The public profile of whoever has [handle] (without its `@`) on the
/// instance; null when nobody has.
Future<PublicProfile?> byHandle(String handle) => _gateway.byHandle(handle);
/// 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(),
],
);
/// Why [handle] couldn't be the person's, as saving would refuse it: not
/// a handle, reserved, someone else's, or a new one once they have
/// published; null when it can, as their own can (decided 2026-10-12, to
/// say so while it is typed).
Future<ProfileProblem?> checkHandle(String handle) =>
_gateway.checkHandle(handle.trim());
/// Removes what the person's public profile shows, making it private
/// again with its handle (ADR 0020, D27); returns what is left, null while
/// they have none. Throws [ProfileRefused] once they have published.
Future<PublicProfile?> 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();
/// Has the instance check which of the person's links' pages link back to
/// their profile page with `rel="me"` (ADR 0011). Throws [ProfileRefused]
/// when it was asked too often.
Future<PublicProfile> checkLinks() => _gateway.checkLinks();
}

View file

@ -0,0 +1,133 @@
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 [],
/// Those of [links] whose pages link back to [pageUrl] with `rel="me"`,
/// as the instance last checked (ADR 0011).
final List<String> verifiedLinks = const [],
/// The profile's page on the web (`https://halkyon.eu/@raisondetredev`),
/// which the person's other pages link back to; null where the instance
/// has none.
final String? pageUrl,
/// 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 {
/// This profile with [verified] as its links checked, those it still has.
PublicProfile withVerifiedLinks(List<String> verified) => PublicProfile(
handle: handle,
address: address,
displayName: displayName,
bio: bio,
links: links,
verifiedLinks: [
for (final link in verified)
if (links.contains(link)) link,
],
pageUrl: pageUrl,
avatarUrl: avatarUrl,
handleFixed: handleFixed,
);
/// The most links a profile holds, as the instance allows.
static const maxLinks = 4;
/// The longest a display name may be, as the instance allows.
static const maxDisplayNameLength = 30;
/// The longest a handle may be (Mastodon's rule, as the instance's).
static const maxHandleLength = 30;
/// The longest a bio may be, as the instance allows.
static const maxBioLength = 500;
/// The longest a link may be, as the instance allows.
static const maxLinkLength = 2000;
@override
List<Object?> get props => [
handle,
address,
displayName,
bio,
links,
verifiedLinks,
pageUrl,
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 links were checked too often in the last hour.
tooManyLinkChecks,
/// Too many handles were asked about in the last hour.
tooManyHandleChecks;
/// The problems with a handle.
static const handleProblems = {
handleInvalid,
handleReserved,
handleTaken,
published,
tooManyHandleChecks,
};
}
/// 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)';
}

View file

@ -0,0 +1,142 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
import 'package:halkyon_app/src/app/app_scope.dart';
import 'package:halkyon_app/src/auth/bloc/sign_in_ways_cubit.dart';
import 'package:halkyon_app/src/password/widget/account_security_section.dart';
import 'package:halkyon_app/src/profile/widget/public_profile_section.dart';
import 'package:halkyon_app/src/simplelogin/widget/simplelogin_section.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Every Halkyon app's Profile (decided 2026-11-02, the developer's wish
/// for one profile in function and look across the apps): the person's
/// own tab first, the same in every app (their account and the ways they
/// sign in, SimpleLogin and the password with its authenticators, then the
/// app's own [sections], then the public profile others see, one for all
/// apps), and after it the app's own [tabs] (Learn's statistics, Train's
/// progress). With several tabs, the tab row folds into the title as the
/// content scrolls ([FoldingTabs], as Strava's); with none of the app's,
/// the person's tab alone, under the title. The [actions] stand in the app
/// bar (the app's Settings); the app's colour washes down from the top
/// ([TopGradient], [color], into [background]).
class const HalkyonProfile({
final List<Widget> actions = const [],
final List<Widget> sections = const [],
final List<FoldingTab> tabs = const [],
final Color? color,
final Color? background,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.of(context);
final mine = _Mine(sections: sections);
return BlocProvider(
create: (context) => SignInWaysCubit(AppScope.of(context).auth)..load(),
child: TopGradient(
color: color,
background: background ?? Theme.of(context).colorScheme.surface,
child: tabs.isEmpty
? Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
backgroundColor: Colors.transparent,
systemOverlayStyle: TopGradient.overlayStyle(context),
title: Text(l10n.profileTitle),
actions: actions,
),
body: mine,
)
: FoldingTabs(
title: l10n.profileTitle,
actions: actions,
tabs: [
FoldingTab(label: l10n.profileTabProfile, body: mine),
...tabs,
],
),
),
);
}
}
/// The person's own tab: a card for each part, as Settings groups its own,
/// in the middle and as wide as readable text.
class const _Mine({required final List<Widget> sections})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.of(context);
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth),
child: ListView(
// Room at the end for the navigation bar floating over the foot.
padding: EdgeInsets.fromLTRB(
12,
12,
12,
12 +
FloatingNavigationBar.coverOf(context) +
MediaQuery.paddingOf(context).bottom,
),
children: [
SectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.profileAccount,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(l10n.profileIdentified),
const _SignInWays(),
],
),
),
for (final section in sections) ...[
const SizedBox(height: 12),
SectionCard(child: section),
],
const SizedBox(height: 12),
const SectionCard(child: PublicProfileSection()),
],
),
),
);
}
}
/// How the person signs in: an email address, and SimpleLogin (Halkyon
/// Learn's ADR 0020, D21), with the password and authenticators beneath.
class const _SignInWays() extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.of(context);
final theme = Theme.of(context);
final ways = context.watch<SignInWaysCubit>().state;
Widget way(String label, String linkedAs) => ListTile(
contentPadding: EdgeInsets.zero,
title: Text(label),
subtitle: Text(linkedAs, maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: Icon(LucideIcons.circleCheck, color: theme.colorScheme.primary),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 16),
Text(l10n.profileSignInWays, style: theme.textTheme.titleSmall),
if (ways.ways.email)
way(l10n.profileWayEmail, ways.emailAddress ?? l10n.profileWayLinked),
// SimpleLogin (ADR 0020, D21): once added, one more way in here.
const SimpleLoginSection(gapAbove: 16),
const SizedBox(height: 16),
// The password and authenticators, and setting them up (ADR 0017),
// which brings the address.
const AccountSecuritySection(),
],
);
}
}

View file

@ -0,0 +1,134 @@
import 'dart:async';
import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
import 'package:halkyon_app/src/app/app_scope.dart';
import 'package:halkyon_app/src/profile/model/public_profile.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
import 'package:url_launcher/url_launcher.dart';
/// Shows the public profile of whoever has [handle] (without its `@`) on
/// the instance, as a studyset names its author or licence holder
/// (decided 2026-10-14): their avatar, name and address, what they say
/// about themselves and their links; that nobody has it, when nobody has.
Future<void> showProfileOf(BuildContext context, String handle) {
final profiles = AppScope.of(context).profiles;
return showAdaptiveSheet<void>(
context,
builder: (context) => _ProfileSheet(profile: profiles.byHandle(handle)),
);
}
class const _ProfileSheet({required final Future<PublicProfile?> profile})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.of(context);
final theme = Theme.of(context);
final muted = theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
return FutureBuilder(
future: profile,
builder: (context, snapshot) {
final found = snapshot.data;
return SheetScaffold(
title: found == null
? l10n.profileSheetTitle
: found.displayName.isEmpty
? '@${found.handle}'
: found.displayName,
body: switch (snapshot.connectionState) {
ConnectionState.done when found == null => Text(
snapshot.hasError
? l10n.profileSheetUnreachable
: l10n.profileSheetNotFound,
style: muted,
),
ConnectionState.done => _Profile(profile: found!),
_ => const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: AppLoadingIndicator()),
),
},
);
},
);
}
}
class const _Profile({required final PublicProfile profile})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final muted = theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
radius: 28,
foregroundImage: switch (profile.avatarUrl) {
final url? => NetworkImage(url),
null => null,
},
child: const Icon(LucideIcons.user),
),
const SizedBox(width: 16),
Expanded(
child: Text(
'@${profile.address}',
style: muted,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
if (profile.bio.isNotEmpty) ...[
const SizedBox(height: 16),
Text(profile.bio),
],
if (profile.links.isNotEmpty) ...[
const SizedBox(height: 12),
for (final link in profile.links)
ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
leading: const Icon(LucideIcons.link),
title: Text(
link.replaceFirst(RegExp('^https?://'), ''),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// Its page links back to this profile's (ADR 0011).
trailing: profile.verifiedLinks.contains(link)
? Tooltip(
message: HalkyonAppLocalizations.of(
context,
).profileSheetLinkVerified,
child: Icon(
LucideIcons.badgeCheck,
color: theme.colorScheme.primary,
semanticLabel: HalkyonAppLocalizations.of(
context,
).profileSheetLinkVerified,
),
)
: null,
onTap: () => unawaited(
launchUrl(
Uri.parse(link.contains('://') ? link : 'https://$link'),
),
),
),
],
],
);
}
}

View file

@ -0,0 +1,774 @@
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
import 'package:halkyon_app/src/app/app_scope.dart';
import 'package:halkyon_app/src/profile/bloc/profile_handle_cubit.dart';
import 'package:halkyon_app/src/profile/bloc/public_profile_bloc.dart';
import 'package:halkyon_app/src/profile/model/public_profile.dart';
import 'package:halkyon_ui/halkyon_ui.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 handle, display name, bio and links that
/// saves as it is written; a way to remove what it shows, making it private
/// with its handle (ADR 0020, D27), until the person publishes.
class const PublicProfileSection({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) => BlocProvider(
create: (context) =>
PublicProfileBloc(AppScope.of(context).profiles)
..add(const PublicProfileEventStarted()),
child: const _Section(),
);
}
class const _Section() extends StatefulWidget {
@override
State<_Section> createState() => _SectionState();
}
class _SectionState() extends State<_Section> {
/// How often the profile was removed here: the form starts afresh each
/// time, from what is left.
var _removals = 0;
@override
Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.of(context);
final theme = Theme.of(context);
return BlocConsumer<PublicProfileBloc, PublicProfileState>(
// Saving as one writes says so beside the title, not in a message.
listenWhen: (before, now) =>
now is PublicProfileStateReady &&
now.outcome != null &&
now.outcome != PublicProfileOutcome.saved,
listener: (context, state) {
if (state case PublicProfileStateReady(
outcome: PublicProfileOutcome.removed,
)) {
setState(() => _removals++);
}
final message = switch (state) {
PublicProfileStateReady(outcome: PublicProfileOutcome.removed) =>
l10n.publicProfileRemoved,
PublicProfileStateReady(
outcome: PublicProfileOutcome.linksChecked,
:final profile?,
) =>
l10n.publicProfileLinksChecked(
profile.links.length,
profile.verifiedLinks.length,
),
_ => l10n.publicProfileFailed,
};
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(message)));
},
builder: (context, state) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
l10n.publicProfileTitle,
style: theme.textTheme.titleMedium,
),
),
if (state case PublicProfileStateReady(
:final saving,
:final outcome,
))
_SaveStatus(
saving: saving,
saved: outcome == PublicProfileOutcome.saved,
),
],
),
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 checkingLinks,
:final problem,
) =>
_ProfileForm(
// Kept while the person writes, as saves come back; afresh
// once a profile is made or removed.
key: ValueKey((profile != null, _removals)),
profile: profile,
saving: saving,
checkingLinks: checkingLinks,
problem: problem,
),
},
],
),
);
}
}
/// Whether what was written is being saved, or was.
class const _SaveStatus({required final bool saving, required final bool saved})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.of(context);
final theme = Theme.of(context);
final text = saving
? l10n.publicProfileSaving
: saved
? l10n.publicProfileSavedNow
: null;
return AnimatedOpacity(
opacity: text == null ? 0 : 1,
duration: const Duration(milliseconds: 200),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!saving && saved) ...[
Icon(LucideIcons.check, size: 16, color: theme.extras.success),
const SizedBox(width: 4),
],
Text(
text ?? '',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}
/// The profile as a form that saves as it is written (decided 2026-10-12):
/// a field when it is left or Enter is pressed in it (the bio, of several
/// lines, when it is left), a link when removed. The handle is chosen in a
/// dialog that says while typing whether it is free; without a profile,
/// choosing it makes the profile with what is written. Once the person has
/// published, the handle stays.
class const _ProfileForm({
required final PublicProfile? profile,
required final bool saving,
required final bool checkingLinks,
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 _bio = TextEditingController(text: widget.profile?.bio);
late final _links = [
for (final link in widget.profile?.links ?? const <String>[])
TextEditingController(text: link),
];
/// The fields that save when left.
late final _displayNameFocus = _savingWhenLeft();
late final _bioFocus = _savingWhenLeft();
final _linkFocus = <FocusNode>[];
@override
void initState() {
super.initState();
_linkFocus.addAll([for (final _ in _links) _savingWhenLeft()]);
}
FocusNode _savingWhenLeft() {
final node = FocusNode();
node.addListener(() {
if (!node.hasFocus) {
_saveIfChanged();
}
});
return node;
}
@override
void dispose() {
for (final controller in [_displayName, _bio, ..._links]) {
controller.dispose();
}
for (final node in [_displayNameFocus, _bioFocus, ..._linkFocus]) {
node.dispose();
}
super.dispose();
}
/// What was last sent to be saved, as [_written] puts it; null before.
String? _sent;
/// The name, bio and links written, as one string to compare.
String get _written => [
_displayName.text.trim(),
_bio.text.trim(),
for (final link in _links)
if (link.text.trim().isNotEmpty) link.text.trim(),
].join('\u0000');
/// Saves what is written, if a profile is there and it differs from what
/// was last saved or sent to be: Enter in a field also leaves it.
void _saveIfChanged() {
final profile = widget.profile;
if (profile == null || !mounted) {
return;
}
final saved =
_sent ??
[
profile.displayName,
profile.bio,
...profile.links,
].join('\u0000');
if (_written != saved) {
_save();
}
}
/// Saves what is written, with [handle] if one was chosen.
void _save({String? handle}) {
_sent = _written;
context.read<PublicProfileBloc>().add(
PublicProfileEventSaved(
handle: handle,
displayName: _displayName.text,
bio: _bio.text,
links: [for (final link in _links) link.text],
),
);
}
Future<void> _chooseHandle() async {
final current = widget.profile?.handle;
if (await chooseHandle(context, current: current) case final handle?
when handle != current && mounted) {
_save(handle: handle);
}
}
void _removeLink(int index) {
setState(() {
_links.removeAt(index).dispose();
_linkFocus.removeAt(index).dispose();
});
_saveIfChanged();
}
/// Whether [profile] shows anything removing would take away: with the
/// handle alone it is private already (ADR 0020, D5).
static bool _showsSomething(PublicProfile profile) =>
profile.displayName.isNotEmpty ||
profile.bio.isNotEmpty ||
profile.links.isNotEmpty ||
profile.avatarUrl != null;
Future<void> _remove() async {
final l10n = HalkyonAppLocalizations.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 = HalkyonAppLocalizations.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,
};
final handleProblem = said(ProfileProblem.handleProblems);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Picture(
profile: profile,
saving: widget.saving,
problem: said({ProfileProblem.avatarInvalid}),
),
const SizedBox(height: 16),
ChevronField(
label: l10n.publicProfileHandle,
value: switch (profile) {
final profile? => '@${profile.handle}',
null => null,
},
placeholder: l10n.publicProfileHandleChoose,
maxLines: 1,
onTap: fixed ? null : _chooseHandle,
helperText: switch (profile) {
PublicProfile(handleFixed: true) => l10n.publicProfileHandleFixed,
// Its handle alone is private (ADR 0020, D5), as the instance's
// `isPublic` has it: nothing published, nothing filled in.
final profile? when !_showsSomething(profile) =>
l10n.publicProfileAddressPrivate(profile.address),
final profile? => l10n.publicProfileAddress(profile.address),
null => l10n.publicProfileHandleFirst,
},
errorText: handleProblem,
),
const SizedBox(height: 16),
TextField(
maxLength: PublicProfile.maxDisplayNameLength,
buildCounter: nearLimitCounter,
controller: _displayName,
focusNode: _displayNameFocus,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _saveIfChanged(),
decoration: InputDecoration(
labelText: l10n.publicProfileDisplayName,
hintText: l10n.publicProfileDisplayNameHint,
errorText: said({ProfileProblem.displayNameTooLong}),
),
),
const SizedBox(height: 12),
TextField(
maxLength: PublicProfile.maxBioLength,
buildCounter: nearLimitCounter,
controller: _bio,
focusNode: _bioFocus,
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,
),
),
if (profile?.pageUrl case final page?) _ProfilePage(url: page),
const SizedBox(height: 8),
for (final (i, link) in _links.indexed)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Expanded(
child: TextField(
maxLength: PublicProfile.maxLinkLength,
buildCounter: nearLimitCounter,
controller: link,
focusNode: _linkFocus[i],
keyboardType: TextInputType.url,
textInputAction: TextInputAction.done,
autocorrect: false,
onSubmitted: (_) => _saveIfChanged(),
decoration: InputDecoration(
hintText: l10n.publicProfileLinkHint,
isDense: true,
),
),
),
if (profile?.verifiedLinks.contains(link.text.trim()) ?? false)
Tooltip(
message: l10n.publicProfileLinkVerified,
child: Padding(
padding: const EdgeInsets.only(left: 8),
child: Icon(
LucideIcons.badgeCheck,
size: 20,
color: theme.colorScheme.primary,
semanticLabel: l10n.publicProfileLinkVerified,
),
),
),
IconButton(
tooltip: l10n.publicProfileRemoveLink,
icon: const Icon(LucideIcons.x, size: 18),
onPressed: () => _removeLink(i),
),
],
),
),
if (_links.length < PublicProfile.maxLinks)
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton.icon(
onPressed: () {
final focus = _savingWhenLeft();
setState(() {
_links.add(TextEditingController());
_linkFocus.add(focus);
});
// Into the new field, once it is there.
WidgetsBinding.instance.addPostFrameCallback(
(_) => focus.requestFocus(),
);
},
icon: const Icon(LucideIcons.plus, size: 18),
label: Text(l10n.publicProfileAddLink),
),
),
if (profile != null && profile.links.isNotEmpty) ...[
if (said({ProfileProblem.tooManyLinkChecks}) case final problem?)
Text(
problem,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton.icon(
onPressed: widget.saving || widget.checkingLinks
? null
: () => context.read<PublicProfileBloc>().add(
const PublicProfileEventLinksChecked(),
),
icon: widget.checkingLinks
? const ButtonLoadingIndicator()
: const Icon(LucideIcons.badgeCheck, size: 18),
label: Text(l10n.publicProfileCheckLinks),
),
),
],
if (profile != null && !fixed && _showsSomething(profile)) ...[
const SizedBox(height: 16),
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton(
onPressed: widget.saving ? null : _remove,
child: Text(l10n.publicProfileRemove),
),
),
],
],
);
}
}
/// Asks for a handle in a dialog, saying while it is typed whether it
/// could be the person's ([ProfileHandleCubit]); completes with the one
/// chosen, or null when the person cancels. [current] is the person's own
/// handle, if they have one.
Future<String?> chooseHandle(BuildContext context, {required String? current}) {
final profiles = AppScope.of(context).profiles;
return showDialog<String>(
context: context,
builder: (context) => BlocProvider(
create: (context) => ProfileHandleCubit(profiles, current: current),
child: _HandleDialog(current: current),
),
);
}
class const _HandleDialog({required final String? current})
extends StatefulWidget {
@override
State<_HandleDialog> createState() => _HandleDialogState();
}
class _HandleDialogState() extends State<_HandleDialog> {
late final _handle = TextEditingController(text: widget.current);
@override
void dispose() {
_handle.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.of(context);
final theme = Theme.of(context);
final state = context.watch<ProfileHandleCubit>().state;
// The instance decides when it couldn't be asked.
final choosable =
state.handle.isNotEmpty &&
state.handle != widget.current &&
(state.check == ProfileHandleCheck.free ||
state.check == ProfileHandleCheck.unknown);
void choose() => Navigator.of(context).pop(state.handle);
return AlertDialog(
title: Text(
widget.current == null
? l10n.publicProfileHandleChooseTitle
: l10n.publicProfileHandleChangeTitle,
),
content: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.publicProfileHandleDialogText),
const SizedBox(height: 16),
TextField(
controller: _handle,
autofocus: true,
maxLength: PublicProfile.maxHandleLength,
buildCounter: nearLimitCounter,
autocorrect: false,
enableSuggestions: false,
textInputAction: TextInputAction.done,
onChanged: (handle) =>
context.read<ProfileHandleCubit>().typed(handle),
onSubmitted: (_) => choosable ? choose() : null,
decoration: requiredDecoration(
context,
InputDecoration(
labelText: l10n.publicProfileHandle,
hintText: l10n.publicProfileHandleHint,
prefixText: '@',
errorText: switch (state) {
ProfileHandleState(
check: ProfileHandleCheck.refused,
:final problem?,
) =>
_problemText(l10n, problem),
_ => null,
},
helperText: switch (state.check) {
ProfileHandleCheck.checking =>
l10n.publicProfileHandleChecking,
ProfileHandleCheck.free => l10n.publicProfileHandleFree(
state.handle,
),
ProfileHandleCheck.none when state.handle.isNotEmpty =>
l10n.publicProfileHandleYours,
_ => null,
},
helperStyle: state.check == ProfileHandleCheck.free
? theme.textTheme.bodySmall?.copyWith(
color: theme.extras.success,
)
: null,
),
missing: state.handle.trim().isEmpty,
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
FilledButton(
onPressed: choosable ? choose : null,
child: Text(
widget.current == null
? l10n.publicProfileHandleConfirmChoose
: l10n.publicProfileHandleConfirmChange,
),
),
],
);
}
}
/// 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 = HalkyonAppLocalizations.of(context);
final bloc = context.read<PublicProfileBloc>();
final picked = await AppScope.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 = HalkyonAppLocalizations.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(
HalkyonAppLocalizations 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,
ProfileProblem.tooManyLinkChecks => l10n.profileProblemTooManyLinkChecks,
ProfileProblem.tooManyHandleChecks => l10n.profileProblemTooManyHandleChecks,
};
/// The profile's page on the web, to link back to, with a way to copy its
/// address, and what linking back to it does (ADR 0011).
class const _ProfilePage({required final String url}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.of(context);
final theme = Theme.of(context);
final muted = theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
l10n.publicProfilePage(
url.replaceFirst(RegExp('^https?://'), ''),
),
style: theme.textTheme.bodyMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
tooltip: l10n.publicProfilePageCopy,
icon: const Icon(LucideIcons.copy, size: 18),
onPressed: () async {
final messenger = ScaffoldMessenger.of(context);
await Clipboard.setData(ClipboardData(text: url));
messenger
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text(l10n.publicProfilePageCopied)),
);
},
),
],
),
Text(l10n.publicProfileVerifyHint, style: muted),
],
),
);
}
}

View file

@ -0,0 +1,32 @@
import 'package:halkyon_app/src/files/file_picker.dart';
/// A [FilePicker] that "picks" [next], if set, and notes what it was asked
/// for.
class FakeFilePicker() implements FilePicker {
/// What the next pick gives; null as if the person cancelled.
PickedFile? next;
/// What the next pick of several gives; [next] alone if null.
List<PickedFile>? nextMany;
/// The extensions of each pick asked for, in order.
final asked = <List<String>>[];
@override
Future<PickedFile?> pick({
required String label,
required List<String> extensions,
}) async {
asked.add(extensions);
return next;
}
@override
Future<List<PickedFile>> pickMany({
required String label,
required List<String> extensions,
}) async {
asked.add(extensions);
return nextMany ?? [?next];
}
}

Some files were not shown because too many files have changed in this diff Show more