feat: public profiles, a handle on the account domain with name, bio, links and picture

Stage 1 of the public tier (ADR 0005). A person with an identity has a
public profile: a handle by Mastodon's rules (1 to 30 letters, digits or
underscores, unique whatever its case, a few kept for the instance),
changeable until they first publish, on the instance's account domain
(ACCOUNT_DOMAIN, else the server's host; the instance info names it), so
`raisondetredev@halkyon.eu`; a display name, a bio, up to four links and
an avatar. The table is server only: the app gets a Profile without the
person's id. ProfileEndpoint (identified only) reads, saves and removes
the caller's own; ProfilesEndpoint finds anyone's by handle.

An avatar is a PNG of at most 1 MB that the app draws anew from the
picture chosen, its middle square at 400 pixels, so a photo's metadata
never leaves the device. The server keeps it in the public storage named
by its content, so its URL tells nothing about whose it is, and deletes it
once no profile shows it. Deleting a person deletes their profile and its
picture.

In the app, the Profile tab holds the profile as a form, laid out as the
old app's; anonymous people are told GitHub sign-in gives them one.

Migration 20261009184924904-public-profiles creates public_profile with a
unique index on the person and one on the handle in lower case; it touches
no other table, and the old server never sees it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-09 20:55:16 +02:00
commit 7f6147d689
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
20 changed files with 16609 additions and 3 deletions

View file

@ -391,6 +391,44 @@
"profileSwitchMessage": "Dieses GitHub-Konto hat bereits Fortschritt von einem anderen Gerät. Die Lernsets auf diesem Gerät lassen sich noch nicht damit zusammenführen: Wenn du wechselst, werden sie gelöscht.",
"profileSwitchStay": "Anonym bleiben",
"profileSwitchConfirm": "Wechseln",
"publicProfileTitle": "Öffentliches Profil",
"publicProfileIntro": "Wie andere dich sehen, sobald du veröffentlichst: das Handle, unter dem sie dich finden, dein Name, ein paar Worte und Links. Nichts davon ist sichtbar, bevor du es speicherst.",
"publicProfileAnonymousHint": "Melde dich mit GitHub an, um ein öffentliches Profil zu haben und zu veröffentlichen.",
"publicProfileDisplayName": "Anzeigename",
"publicProfileDisplayNameHint": "Der Name neben deinem Handle",
"publicProfileHandle": "Handle",
"publicProfileHandleHint": "Buchstaben, Ziffern und Unterstriche, bis zu 30",
"publicProfileAddress": "Andere finden dich als @{address}",
"@publicProfileAddress": {"placeholders": {"address": {"type": "String"}}},
"publicProfileHandleFixed": "Fest, seit du veröffentlicht hast: Andere verweisen darauf.",
"publicProfileBio": "Über dich",
"publicProfileLinks": "Links",
"publicProfileLinksHint": "Deine Website oder andere Konten, bis zu 4.",
"publicProfileLinkHint": "https://…",
"publicProfileAddLink": "Link hinzufügen",
"publicProfileRemoveLink": "Link entfernen",
"publicProfileSaved": "Profil gespeichert.",
"publicProfileRemove": "Profil entfernen",
"publicProfileRemoveTitle": "Dein öffentliches Profil entfernen?",
"publicProfileRemoveMessage": "Dein Handle wird für andere frei.",
"publicProfileRemoved": "Profil entfernt.",
"publicProfileFailed": "Die Instanz ist nicht erreichbar. Versuche es erneut.",
"publicProfileUnreachable": "Dein öffentliches Profil ist gerade nicht erreichbar.",
"publicProfileRetry": "Erneut versuchen",
"profileProblemHandleInvalid": "Ein Handle besteht aus 1 bis 30 Buchstaben (a bis z), Ziffern oder Unterstrichen.",
"profileProblemHandleReserved": "Dieses Handle ist der Instanz vorbehalten.",
"profileProblemHandleTaken": "Dieses Handle hat schon jemand anderes.",
"profileProblemPublished": "Du hast veröffentlicht: Dein Handle kann sich nicht mehr ändern und dein Profil nicht mehr gehen.",
"profileProblemDisplayNameTooLong": "Höchstens 30 Zeichen.",
"profileProblemBioTooLong": "Höchstens 500 Zeichen.",
"profileProblemTooManyLinks": "Höchstens 4 Links.",
"profileProblemLinkInvalid": "Links beginnen mit https:// oder http://.",
"profileProblemAvatarInvalid": "Dieses Bild lässt sich nicht verwenden: Wähle ein PNG-, JPEG-, WebP- oder GIF-Bild.",
"publicProfilePicture": "Bild",
"publicProfileChoosePicture": "Bild wählen",
"publicProfileRemovePicture": "Bild entfernen",
"publicProfilePictureType": "Bilder",
"publicProfilePictureLater": "Speichere dein Profil, um ein Bild hinzuzufügen.",
"settingsTitle": "Einstellungen",
"settingsAccount": "Konto",

View file

@ -423,6 +423,44 @@
"profileSwitchMessage": "This GitHub account already has progress from another device. The studysets on this device can't be combined with it yet: if you switch, they are deleted.",
"profileSwitchStay": "Stay anonymous",
"profileSwitchConfirm": "Switch",
"publicProfileTitle": "Public profile",
"publicProfileIntro": "How others see you once you publish: the handle they find you by, your name, a few words and links. Nothing here shows until you save it.",
"publicProfileAnonymousHint": "Sign in with GitHub to have a public profile and publish.",
"publicProfileDisplayName": "Display name",
"publicProfileDisplayNameHint": "The name shown beside your handle",
"publicProfileHandle": "Handle",
"publicProfileHandleHint": "Letters, digits and underscores, up to 30",
"publicProfileAddress": "Others find you as @{address}",
"@publicProfileAddress": {"placeholders": {"address": {"type": "String"}}},
"publicProfileHandleFixed": "Fixed since you published: others refer to it.",
"publicProfileBio": "Bio",
"publicProfileLinks": "Links",
"publicProfileLinksHint": "Your site or other accounts, up to 4.",
"publicProfileLinkHint": "https://…",
"publicProfileAddLink": "Add link",
"publicProfileRemoveLink": "Remove link",
"publicProfileSaved": "Profile saved.",
"publicProfileRemove": "Remove profile",
"publicProfileRemoveTitle": "Remove your public profile?",
"publicProfileRemoveMessage": "Your handle becomes free for others to take.",
"publicProfileRemoved": "Profile removed.",
"publicProfileFailed": "Couldn't reach the instance. Try again.",
"publicProfileUnreachable": "Your public profile can't be reached right now.",
"publicProfileRetry": "Retry",
"profileProblemHandleInvalid": "A handle is 1 to 30 letters (a to z), digits or underscores.",
"profileProblemHandleReserved": "This handle is kept for the instance.",
"profileProblemHandleTaken": "Someone else has this handle.",
"profileProblemPublished": "You have published: your handle can't change, nor your profile go.",
"profileProblemDisplayNameTooLong": "At most 30 characters.",
"profileProblemBioTooLong": "At most 500 characters.",
"profileProblemTooManyLinks": "At most 4 links.",
"profileProblemLinkInvalid": "Links start with https:// or http://.",
"profileProblemAvatarInvalid": "This picture can't be used: choose a PNG, JPEG, WebP or GIF image.",
"publicProfilePicture": "Picture",
"publicProfileChoosePicture": "Choose picture",
"publicProfileRemovePicture": "Remove picture",
"publicProfilePictureType": "Images",
"publicProfilePictureLater": "Save your profile to add a picture.",
"settingsTitle": "Settings",
"settingsAccount": "Account",

View file

@ -2380,6 +2380,228 @@ abstract class AppLocalizations {
/// **'Switch'**
String get profileSwitchConfirm;
/// No description provided for @publicProfileTitle.
///
/// In en, this message translates to:
/// **'Public profile'**
String get publicProfileTitle;
/// No description provided for @publicProfileIntro.
///
/// In en, this message translates to:
/// **'How others see you once you publish: the handle they find you by, your name, a few words and links. Nothing here shows until you save it.'**
String get publicProfileIntro;
/// No description provided for @publicProfileAnonymousHint.
///
/// In en, this message translates to:
/// **'Sign in with GitHub to have a public profile and publish.'**
String get publicProfileAnonymousHint;
/// No description provided for @publicProfileDisplayName.
///
/// In en, this message translates to:
/// **'Display name'**
String get publicProfileDisplayName;
/// No description provided for @publicProfileDisplayNameHint.
///
/// In en, this message translates to:
/// **'The name shown beside your handle'**
String get publicProfileDisplayNameHint;
/// No description provided for @publicProfileHandle.
///
/// In en, this message translates to:
/// **'Handle'**
String get publicProfileHandle;
/// No description provided for @publicProfileHandleHint.
///
/// In en, this message translates to:
/// **'Letters, digits and underscores, up to 30'**
String get publicProfileHandleHint;
/// No description provided for @publicProfileAddress.
///
/// In en, this message translates to:
/// **'Others find you as @{address}'**
String publicProfileAddress(String address);
/// No description provided for @publicProfileHandleFixed.
///
/// In en, this message translates to:
/// **'Fixed since you published: others refer to it.'**
String get publicProfileHandleFixed;
/// No description provided for @publicProfileBio.
///
/// In en, this message translates to:
/// **'Bio'**
String get publicProfileBio;
/// No description provided for @publicProfileLinks.
///
/// In en, this message translates to:
/// **'Links'**
String get publicProfileLinks;
/// No description provided for @publicProfileLinksHint.
///
/// In en, this message translates to:
/// **'Your site or other accounts, up to 4.'**
String get publicProfileLinksHint;
/// No description provided for @publicProfileLinkHint.
///
/// In en, this message translates to:
/// **'https://…'**
String get publicProfileLinkHint;
/// No description provided for @publicProfileAddLink.
///
/// In en, this message translates to:
/// **'Add link'**
String get publicProfileAddLink;
/// No description provided for @publicProfileRemoveLink.
///
/// In en, this message translates to:
/// **'Remove link'**
String get publicProfileRemoveLink;
/// No description provided for @publicProfileSaved.
///
/// In en, this message translates to:
/// **'Profile saved.'**
String get publicProfileSaved;
/// No description provided for @publicProfileRemove.
///
/// In en, this message translates to:
/// **'Remove profile'**
String get publicProfileRemove;
/// No description provided for @publicProfileRemoveTitle.
///
/// In en, this message translates to:
/// **'Remove your public profile?'**
String get publicProfileRemoveTitle;
/// No description provided for @publicProfileRemoveMessage.
///
/// In en, this message translates to:
/// **'Your handle becomes free for others to take.'**
String get publicProfileRemoveMessage;
/// No description provided for @publicProfileRemoved.
///
/// In en, this message translates to:
/// **'Profile removed.'**
String get publicProfileRemoved;
/// No description provided for @publicProfileFailed.
///
/// In en, this message translates to:
/// **'Couldn\'t reach the instance. Try again.'**
String get publicProfileFailed;
/// No description provided for @publicProfileUnreachable.
///
/// In en, this message translates to:
/// **'Your public profile can\'t be reached right now.'**
String get publicProfileUnreachable;
/// No description provided for @publicProfileRetry.
///
/// In en, this message translates to:
/// **'Retry'**
String get publicProfileRetry;
/// No description provided for @profileProblemHandleInvalid.
///
/// In en, this message translates to:
/// **'A handle is 1 to 30 letters (a to z), digits or underscores.'**
String get profileProblemHandleInvalid;
/// No description provided for @profileProblemHandleReserved.
///
/// In en, this message translates to:
/// **'This handle is kept for the instance.'**
String get profileProblemHandleReserved;
/// No description provided for @profileProblemHandleTaken.
///
/// In en, this message translates to:
/// **'Someone else has this handle.'**
String get profileProblemHandleTaken;
/// No description provided for @profileProblemPublished.
///
/// In en, this message translates to:
/// **'You have published: your handle can\'t change, nor your profile go.'**
String get profileProblemPublished;
/// No description provided for @profileProblemDisplayNameTooLong.
///
/// In en, this message translates to:
/// **'At most 30 characters.'**
String get profileProblemDisplayNameTooLong;
/// No description provided for @profileProblemBioTooLong.
///
/// In en, this message translates to:
/// **'At most 500 characters.'**
String get profileProblemBioTooLong;
/// No description provided for @profileProblemTooManyLinks.
///
/// In en, this message translates to:
/// **'At most 4 links.'**
String get profileProblemTooManyLinks;
/// No description provided for @profileProblemLinkInvalid.
///
/// In en, this message translates to:
/// **'Links start with https:// or http://.'**
String get profileProblemLinkInvalid;
/// No description provided for @profileProblemAvatarInvalid.
///
/// In en, this message translates to:
/// **'This picture can\'t be used: choose a PNG, JPEG, WebP or GIF image.'**
String get profileProblemAvatarInvalid;
/// No description provided for @publicProfilePicture.
///
/// In en, this message translates to:
/// **'Picture'**
String get publicProfilePicture;
/// No description provided for @publicProfileChoosePicture.
///
/// In en, this message translates to:
/// **'Choose picture'**
String get publicProfileChoosePicture;
/// No description provided for @publicProfileRemovePicture.
///
/// In en, this message translates to:
/// **'Remove picture'**
String get publicProfileRemovePicture;
/// No description provided for @publicProfilePictureType.
///
/// In en, this message translates to:
/// **'Images'**
String get publicProfilePictureType;
/// No description provided for @publicProfilePictureLater.
///
/// In en, this message translates to:
/// **'Save your profile to add a picture.'**
String get publicProfilePictureLater;
/// No description provided for @settingsTitle.
///
/// In en, this message translates to:

View file

@ -1397,6 +1397,133 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get profileSwitchConfirm => 'Wechseln';
@override
String get publicProfileTitle => 'Öffentliches Profil';
@override
String get publicProfileIntro =>
'Wie andere dich sehen, sobald du veröffentlichst: das Handle, unter dem sie dich finden, dein Name, ein paar Worte und Links. Nichts davon ist sichtbar, bevor du es speicherst.';
@override
String get publicProfileAnonymousHint =>
'Melde dich mit GitHub an, um ein öffentliches Profil zu haben und zu veröffentlichen.';
@override
String get publicProfileDisplayName => 'Anzeigename';
@override
String get publicProfileDisplayNameHint => 'Der Name neben deinem Handle';
@override
String get publicProfileHandle => 'Handle';
@override
String get publicProfileHandleHint =>
'Buchstaben, Ziffern und Unterstriche, bis zu 30';
@override
String publicProfileAddress(String address) {
return 'Andere finden dich als @$address';
}
@override
String get publicProfileHandleFixed =>
'Fest, seit du veröffentlicht hast: Andere verweisen darauf.';
@override
String get publicProfileBio => 'Über dich';
@override
String get publicProfileLinks => 'Links';
@override
String get publicProfileLinksHint =>
'Deine Website oder andere Konten, bis zu 4.';
@override
String get publicProfileLinkHint => 'https://…';
@override
String get publicProfileAddLink => 'Link hinzufügen';
@override
String get publicProfileRemoveLink => 'Link entfernen';
@override
String get publicProfileSaved => 'Profil gespeichert.';
@override
String get publicProfileRemove => 'Profil entfernen';
@override
String get publicProfileRemoveTitle => 'Dein öffentliches Profil entfernen?';
@override
String get publicProfileRemoveMessage => 'Dein Handle wird für andere frei.';
@override
String get publicProfileRemoved => 'Profil entfernt.';
@override
String get publicProfileFailed =>
'Die Instanz ist nicht erreichbar. Versuche es erneut.';
@override
String get publicProfileUnreachable =>
'Dein öffentliches Profil ist gerade nicht erreichbar.';
@override
String get publicProfileRetry => 'Erneut versuchen';
@override
String get profileProblemHandleInvalid =>
'Ein Handle besteht aus 1 bis 30 Buchstaben (a bis z), Ziffern oder Unterstrichen.';
@override
String get profileProblemHandleReserved =>
'Dieses Handle ist der Instanz vorbehalten.';
@override
String get profileProblemHandleTaken =>
'Dieses Handle hat schon jemand anderes.';
@override
String get profileProblemPublished =>
'Du hast veröffentlicht: Dein Handle kann sich nicht mehr ändern und dein Profil nicht mehr gehen.';
@override
String get profileProblemDisplayNameTooLong => 'Höchstens 30 Zeichen.';
@override
String get profileProblemBioTooLong => 'Höchstens 500 Zeichen.';
@override
String get profileProblemTooManyLinks => 'Höchstens 4 Links.';
@override
String get profileProblemLinkInvalid =>
'Links beginnen mit https:// oder http://.';
@override
String get profileProblemAvatarInvalid =>
'Dieses Bild lässt sich nicht verwenden: Wähle ein PNG-, JPEG-, WebP- oder GIF-Bild.';
@override
String get publicProfilePicture => 'Bild';
@override
String get publicProfileChoosePicture => 'Bild wählen';
@override
String get publicProfileRemovePicture => 'Bild entfernen';
@override
String get publicProfilePictureType => 'Bilder';
@override
String get publicProfilePictureLater =>
'Speichere dein Profil, um ein Bild hinzuzufügen.';
@override
String get settingsTitle => 'Einstellungen';

View file

@ -1389,6 +1389,131 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get profileSwitchConfirm => 'Switch';
@override
String get publicProfileTitle => 'Public profile';
@override
String get publicProfileIntro =>
'How others see you once you publish: the handle they find you by, your name, a few words and links. Nothing here shows until you save it.';
@override
String get publicProfileAnonymousHint =>
'Sign in with GitHub to have a public profile and publish.';
@override
String get publicProfileDisplayName => 'Display name';
@override
String get publicProfileDisplayNameHint =>
'The name shown beside your handle';
@override
String get publicProfileHandle => 'Handle';
@override
String get publicProfileHandleHint =>
'Letters, digits and underscores, up to 30';
@override
String publicProfileAddress(String address) {
return 'Others find you as @$address';
}
@override
String get publicProfileHandleFixed =>
'Fixed since you published: others refer to it.';
@override
String get publicProfileBio => 'Bio';
@override
String get publicProfileLinks => 'Links';
@override
String get publicProfileLinksHint => 'Your site or other accounts, up to 4.';
@override
String get publicProfileLinkHint => 'https://…';
@override
String get publicProfileAddLink => 'Add link';
@override
String get publicProfileRemoveLink => 'Remove link';
@override
String get publicProfileSaved => 'Profile saved.';
@override
String get publicProfileRemove => 'Remove profile';
@override
String get publicProfileRemoveTitle => 'Remove your public profile?';
@override
String get publicProfileRemoveMessage =>
'Your handle becomes free for others to take.';
@override
String get publicProfileRemoved => 'Profile removed.';
@override
String get publicProfileFailed => 'Couldn\'t reach the instance. Try again.';
@override
String get publicProfileUnreachable =>
'Your public profile can\'t be reached right now.';
@override
String get publicProfileRetry => 'Retry';
@override
String get profileProblemHandleInvalid =>
'A handle is 1 to 30 letters (a to z), digits or underscores.';
@override
String get profileProblemHandleReserved =>
'This handle is kept for the instance.';
@override
String get profileProblemHandleTaken => 'Someone else has this handle.';
@override
String get profileProblemPublished =>
'You have published: your handle can\'t change, nor your profile go.';
@override
String get profileProblemDisplayNameTooLong => 'At most 30 characters.';
@override
String get profileProblemBioTooLong => 'At most 500 characters.';
@override
String get profileProblemTooManyLinks => 'At most 4 links.';
@override
String get profileProblemLinkInvalid =>
'Links start with https:// or http://.';
@override
String get profileProblemAvatarInvalid =>
'This picture can\'t be used: choose a PNG, JPEG, WebP or GIF image.';
@override
String get publicProfilePicture => 'Picture';
@override
String get publicProfileChoosePicture => 'Choose picture';
@override
String get publicProfileRemovePicture => 'Remove picture';
@override
String get publicProfilePictureType => 'Images';
@override
String get publicProfilePictureLater => 'Save your profile to add a picture.';
@override
String get settingsTitle => 'Settings';

View file

@ -47,6 +47,8 @@ import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/sync_pdf_sourc
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_platform.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_platforms.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_run_store.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/data/serverpod_profile_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/sync_settings_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
@ -298,6 +300,7 @@ Future<PersonDependencies> _openPerson(
pomodoroRuns: FilePomodoroRunStore(
File(p.join(folder.path, 'pomodoro.json')),
),
profile: ProfileRepository(ServerpodProfileGateway(client)),
onDispose: () async {
try {
// Deletions first: those still waiting are dropped, those under way

View file

@ -10,6 +10,7 @@ import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_run_store.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
@ -75,6 +76,9 @@ class PersonDependencies({
/// Where this device keeps the person's Pomodoro timer.
required final PomodoroRunStore pomodoroRuns,
/// The person's public profile, on the instance.
required final ProfileRepository profile,
final Future<void> Function()? _onDispose,
}) {
/// Closes what was opened for the person.

View file

@ -9,6 +9,7 @@ import 'package:halkyon_learn_flutter/src/core/router/tab_visibility.dart';
import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_trouble_badge.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/github_sign_in_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/widget/public_profile_section.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -98,7 +99,21 @@ class const _Account() extends StatelessWidget {
Text(
isIdentified ? l10n.profileIdentified : l10n.profileAnonymous,
),
if (!isIdentified) const _GitHubSignIn(),
if (!isIdentified) ...[
const _GitHubSignIn(),
const SizedBox(height: 24),
Text(
l10n.publicProfileAnonymousHint,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
] else ...[
const SizedBox(height: 24),
const Divider(height: 1),
const SizedBox(height: 24),
const PublicProfileSection(),
],
],
),
),

View file

@ -0,0 +1,197 @@
import 'dart:developer';
import 'dart:typed_data';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
/// The person's public profile: reading it, saving it, removing it.
class PublicProfileBloc(final ProfileRepository _profiles)
extends Bloc<PublicProfileEvent, PublicProfileState> {
this : super(const PublicProfileStateLoading()) {
on<PublicProfileEventStarted>(_onStarted);
on<PublicProfileEventSaved>(_onSaved);
on<PublicProfileEventRemoved>(_onRemoved);
on<PublicProfileEventAvatarChosen>(
(event, emit) => _change(emit, () => _profiles.setAvatar(event.picture)),
);
on<PublicProfileEventAvatarRemoved>(
(event, emit) => _change(emit, _profiles.removeAvatar),
);
}
/// Changes the profile through [change], as saving it does.
Future<void> _change(
Emitter<PublicProfileState> emit,
Future<PublicProfile> Function() change,
) async {
if (state case PublicProfileStateReady(:final profile?, saving: false)) {
emit(PublicProfileStateReady(profile: profile, saving: true));
try {
emit(
PublicProfileStateReady(
profile: await change(),
outcome: PublicProfileOutcome.saved,
),
);
} on ProfileRefused catch (refused) {
emit(
PublicProfileStateReady(profile: profile, problem: refused.problem),
);
} on Object catch (error) {
log('Changing the avatar failed', name: 'Profile', error: error);
emit(
PublicProfileStateReady(
profile: profile,
outcome: PublicProfileOutcome.failed,
),
);
}
}
}
Future<void> _onStarted(
PublicProfileEventStarted event,
Emitter<PublicProfileState> emit,
) async {
emit(const PublicProfileStateLoading());
try {
emit(PublicProfileStateReady(profile: await _profiles.mine()));
} on Object catch (error) {
log('Reading the profile failed', name: 'Profile', error: error);
emit(const PublicProfileStateUnreachable());
}
}
Future<void> _onSaved(
PublicProfileEventSaved event,
Emitter<PublicProfileState> emit,
) async {
if (state case PublicProfileStateReady(:final profile, saving: false)) {
emit(PublicProfileStateReady(profile: profile, saving: true));
try {
emit(
PublicProfileStateReady(
profile: await _profiles.save(
handle: event.handle,
displayName: event.displayName,
bio: event.bio,
links: event.links,
),
outcome: PublicProfileOutcome.saved,
),
);
} on ProfileRefused catch (refused) {
emit(
PublicProfileStateReady(profile: profile, problem: refused.problem),
);
} on Object catch (error) {
log('Saving the profile failed', name: 'Profile', error: error);
emit(
PublicProfileStateReady(
profile: profile,
outcome: PublicProfileOutcome.failed,
),
);
}
}
}
Future<void> _onRemoved(
PublicProfileEventRemoved event,
Emitter<PublicProfileState> emit,
) async {
if (state case PublicProfileStateReady(:final profile?, saving: false)) {
emit(PublicProfileStateReady(profile: profile, saving: true));
try {
await _profiles.remove();
emit(
const PublicProfileStateReady(outcome: PublicProfileOutcome.removed),
);
} on ProfileRefused catch (refused) {
emit(
PublicProfileStateReady(profile: profile, problem: refused.problem),
);
} on Object catch (error) {
log('Removing the profile failed', name: 'Profile', error: error);
emit(
PublicProfileStateReady(
profile: profile,
outcome: PublicProfileOutcome.failed,
),
);
}
}
}
}
/// What saving or removing came to, said once.
enum PublicProfileOutcome() {
/// Saved.
saved,
/// Removed.
removed,
/// The instance couldn't be reached; nothing changed.
failed,
}
/// Where the person's public profile stands.
sealed class const PublicProfileState() with Equatable {
@override
List<Object?> get props => const [];
}
/// Being read.
final class const PublicProfileStateLoading() extends PublicProfileState;
/// The instance can't be reached.
final class const PublicProfileStateUnreachable() extends PublicProfileState;
/// Read: the [profile] (null while there is none), whether a change is on
/// its way, why the last one was refused ([problem]), and what the last one
/// came to ([outcome]); the last two cleared by the next state.
final class const PublicProfileStateReady({
final PublicProfile? profile,
final bool saving = false,
final ProfileProblem? problem,
final PublicProfileOutcome? outcome,
}) extends PublicProfileState {
@override
List<Object?> get props => [profile, saving, problem, outcome];
}
/// What happens to the person's public profile.
sealed class const PublicProfileEvent() with Equatable {
@override
List<Object?> get props => const [];
}
/// The profile shows: read it.
final class const PublicProfileEventStarted() extends PublicProfileEvent;
/// The person saves their profile as written.
final class const PublicProfileEventSaved({
required final String handle,
required final String displayName,
required final String bio,
required final List<String> links,
}) extends PublicProfileEvent {
@override
List<Object?> get props => [handle, displayName, bio, links];
}
/// The person removes their profile.
final class const PublicProfileEventRemoved() extends PublicProfileEvent;
/// The person chose [picture] as their avatar.
final class const PublicProfileEventAvatarChosen(final Uint8List picture)
extends PublicProfileEvent {
@override
List<Object?> get props => [picture];
}
/// The person removes their avatar.
final class const PublicProfileEventAvatarRemoved() extends PublicProfileEvent;

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,30 @@
import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
/// The boundary to the person's public profile on the instance. Tests
/// replace it with a fake.
abstract interface class ProfileGateway() {
/// The person's public profile; null while they have none.
Future<PublicProfile?> mine();
/// Saves the person's public profile, making it if they have none.
/// Throws [ProfileRefused] when the instance refuses it.
Future<PublicProfile> save({
required String handle,
required String displayName,
required String bio,
required List<String> links,
});
/// Removes the person's public profile. Throws [ProfileRefused] once they
/// have published.
Future<void> remove();
/// Shows [png] as the person's avatar; returns the profile with it.
/// Throws [ProfileRefused] for anything but a PNG of at most 1 MB.
Future<PublicProfile> setAvatar(Uint8List png);
/// Shows no avatar any more; returns the profile without it.
Future<PublicProfile> removeAvatar();
}

View file

@ -0,0 +1,55 @@
import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/feature/profile/data/avatar_image.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
/// The person's public profile (ADR 0005, rule 2). It lives on the
/// instance, not in the person's synced data, so it needs the instance to
/// be reachable. A picture becomes an avatar through [_drawAvatar]
/// ([avatarPng] unless a test gives another).
class ProfileRepository(
final ProfileGateway _gateway, {
final Future<Uint8List> Function(Uint8List picture) _drawAvatar = avatarPng,
}) {
/// The person's public profile; null while they have none.
Future<PublicProfile?> mine() => _gateway.mine();
/// Saves the person's public profile, blank links left out. Throws
/// [ProfileRefused] when the instance refuses it.
Future<PublicProfile> save({
required String handle,
required String displayName,
required String bio,
required List<String> links,
}) => _gateway.save(
handle: handle.trim(),
displayName: displayName.trim(),
bio: bio.trim(),
links: [
for (final link in links)
if (link.trim().isNotEmpty) link.trim(),
],
);
/// Removes the person's public profile. Throws [ProfileRefused] once they
/// have published.
Future<void> remove() => _gateway.remove();
/// Shows [picture] (a PNG, JPEG, WebP or GIF) as the person's avatar,
/// drawn anew as a square PNG ([avatarPng]): metadata such as where a
/// photo was taken never leaves the device. Throws [ProfileRefused] for
/// a picture that can't be read.
Future<PublicProfile> setAvatar(Uint8List picture) async {
final Uint8List png;
try {
png = await _drawAvatar(picture);
} on Object {
throw ProfileRefused(ProfileProblem.avatarInvalid);
}
return await _gateway.setAvatar(png);
}
/// Shows no avatar any more.
Future<PublicProfile> removeAvatar() => _gateway.removeAvatar();
}

View file

@ -0,0 +1,64 @@
import 'dart:typed_data';
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
/// [ProfileGateway] asking the instance through the Serverpod client.
class ServerpodProfileGateway(final protocol.Client _client)
implements ProfileGateway {
@override
Future<PublicProfile?> mine() async => switch (await _client.profile.mine()) {
final profile? => _profile(profile),
null => null,
};
@override
Future<PublicProfile> save({
required String handle,
required String displayName,
required String bio,
required List<String> links,
}) => _refusing(
() async => _profile(
await _client.profile.save(handle, displayName, bio, links),
),
);
@override
Future<void> remove() => _refusing(_client.profile.remove);
@override
Future<PublicProfile> setAvatar(Uint8List png) => _refusing(
() async => _profile(
await _client.profile.setAvatar(ByteData.sublistView(png)),
),
);
@override
Future<PublicProfile> removeAvatar() =>
_refusing(() async => _profile(await _client.profile.removeAvatar()));
/// [call], its refusal as a [ProfileRefused]; a reason this app doesn't
/// know reads as an invalid handle.
Future<T> _refusing<T>(Future<T> Function() call) async {
try {
return await call();
} on protocol.ProfileRefusedException catch (error) {
throw ProfileRefused(
ProfileProblem.values.asNameMap()[error.reason.name] ??
ProfileProblem.handleInvalid,
);
}
}
PublicProfile _profile(protocol.Profile profile) => PublicProfile(
handle: profile.handle,
address: profile.address,
displayName: profile.displayName,
bio: profile.bio,
links: profile.links,
avatarUrl: profile.avatarUrl,
handleFixed: profile.handleFixed,
);
}

View file

@ -0,0 +1,79 @@
import 'package:equatable/equatable.dart';
/// A person's public profile (ADR 0005, rule 2): the handle others find
/// them by, at the instance's account domain ([address],
/// `raisondetredev@halkyon.eu`), a display name, a bio, links and an
/// avatar.
final class const PublicProfile({
/// The handle as its person wrote it.
required final String handle,
/// The handle with the account domain.
required final String address,
/// The name shown beside the handle; empty for none.
final String displayName = '',
/// A few words about the person; empty for none.
final String bio = '',
/// Pages about the person, in their order.
final List<String> links = const [],
/// Where their avatar is, a PNG; null for none.
final String? avatarUrl,
/// Whether the handle is fixed: its person has published.
final bool handleFixed = false,
}) with Equatable {
/// The most links a profile holds, as the instance allows.
static const maxLinks = 4;
@override
List<Object?> get props => [
handle,
address,
displayName,
bio,
links,
avatarUrl,
handleFixed,
];
}
/// Why the instance refused to save or remove a profile.
enum ProfileProblem() {
/// The handle isn't 1 to 30 letters, digits or underscores.
handleInvalid,
/// The handle is kept for the instance.
handleReserved,
/// Someone else has the handle.
handleTaken,
/// The person has published: the handle is fixed, the profile stays.
published,
/// The display name is too long.
displayNameTooLong,
/// The bio is too long.
bioTooLong,
/// Too many links.
tooManyLinks,
/// A link isn't an absolute HTTP(S) URL.
linkInvalid,
/// The picture can't be read, or isn't a PNG of at most 1 MB.
avatarInvalid,
}
/// The instance refused to save or remove a profile, for [problem];
/// nothing changed.
final class ProfileRefused(final ProfileProblem problem) implements Exception {
@override
String toString() => 'ProfileRefused($problem)';
}

View file

@ -0,0 +1,386 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/bloc/public_profile_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The person's public profile (ADR 0005, rule 2), in the Profile tab: what
/// it is for, and a form of its display name, handle, bio and links, saved
/// at once; a way to remove it until the person publishes.
class const PublicProfileSection({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) => BlocProvider(
create: (context) =>
PublicProfileBloc(PersonScope.of(context).profile)
..add(const PublicProfileEventStarted()),
child: const _Section(),
);
}
class const _Section() extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return BlocConsumer<PublicProfileBloc, PublicProfileState>(
listenWhen: (before, now) =>
now is PublicProfileStateReady && now.outcome != null,
listener: (context, state) {
final message = switch (state) {
PublicProfileStateReady(outcome: PublicProfileOutcome.saved) =>
l10n.publicProfileSaved,
PublicProfileStateReady(outcome: PublicProfileOutcome.removed) =>
l10n.publicProfileRemoved,
_ => l10n.publicProfileFailed,
};
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(message)));
},
builder: (context, state) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l10n.publicProfileTitle, style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(
l10n.publicProfileIntro,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
switch (state) {
PublicProfileStateLoading() => const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: AppLoadingIndicator()),
),
PublicProfileStateUnreachable() => Column(
children: [
Text(l10n.publicProfileUnreachable),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: () => context.read<PublicProfileBloc>().add(
const PublicProfileEventStarted(),
),
child: Text(l10n.publicProfileRetry),
),
],
),
PublicProfileStateReady(
:final profile,
:final saving,
:final problem,
) =>
_ProfileForm(
// Each profile the instance answers with starts the form
// afresh, with what it holds.
key: ValueKey(profile),
profile: profile,
saving: saving,
problem: problem,
),
},
],
),
);
}
}
class const _ProfileForm({
required final PublicProfile? profile,
required final bool saving,
required final ProfileProblem? problem,
super.key,
}) extends StatefulWidget {
@override
State<_ProfileForm> createState() => _ProfileFormState();
}
class _ProfileFormState() extends State<_ProfileForm> {
late final _displayName = TextEditingController(
text: widget.profile?.displayName,
);
late final _handle = TextEditingController(text: widget.profile?.handle);
late final _bio = TextEditingController(text: widget.profile?.bio);
late final _links = [
for (final link in widget.profile?.links ?? const <String>[])
TextEditingController(text: link),
];
@override
void dispose() {
_displayName.dispose();
_handle.dispose();
_bio.dispose();
for (final link in _links) {
link.dispose();
}
super.dispose();
}
void _save() => context.read<PublicProfileBloc>().add(
PublicProfileEventSaved(
handle: _handle.text,
displayName: _displayName.text,
bio: _bio.text,
links: [for (final link in _links) link.text],
),
);
Future<void> _remove() async {
final l10n = AppLocalizations.of(context);
final bloc = context.read<PublicProfileBloc>();
if (await confirmDestructiveAction(
context,
title: l10n.publicProfileRemoveTitle,
message: l10n.publicProfileRemoveMessage,
confirmLabel: l10n.publicProfileRemove,
)) {
bloc.add(const PublicProfileEventRemoved());
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final profile = widget.profile;
final fixed = profile?.handleFixed ?? false;
String? said(Set<ProfileProblem> about) => switch (widget.problem) {
final problem? when about.contains(problem) => _problemText(
l10n,
problem,
),
_ => null,
};
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Picture(
profile: profile,
saving: widget.saving,
problem: said({ProfileProblem.avatarInvalid}),
),
const SizedBox(height: 16),
TextField(
controller: _displayName,
decoration: InputDecoration(
labelText: l10n.publicProfileDisplayName,
hintText: l10n.publicProfileDisplayNameHint,
errorText: said({ProfileProblem.displayNameTooLong}),
),
),
const SizedBox(height: 12),
TextField(
controller: _handle,
readOnly: fixed,
autocorrect: false,
enableSuggestions: false,
decoration: InputDecoration(
labelText: l10n.publicProfileHandle,
hintText: l10n.publicProfileHandleHint,
prefixText: '@',
helperText: switch (profile) {
PublicProfile(handleFixed: true) => l10n.publicProfileHandleFixed,
final profile? => l10n.publicProfileAddress(profile.address),
null => null,
},
errorText: said({
ProfileProblem.handleInvalid,
ProfileProblem.handleReserved,
ProfileProblem.handleTaken,
ProfileProblem.published,
}),
),
),
const SizedBox(height: 12),
TextField(
controller: _bio,
minLines: 3,
maxLines: null,
decoration: InputDecoration(
labelText: l10n.publicProfileBio,
errorText: said({ProfileProblem.bioTooLong}),
),
),
const SizedBox(height: 24),
Text(l10n.publicProfileLinks, style: theme.textTheme.titleMedium),
Text(
said({ProfileProblem.tooManyLinks, ProfileProblem.linkInvalid}) ??
l10n.publicProfileLinksHint,
style: theme.textTheme.bodySmall?.copyWith(
color:
widget.problem == ProfileProblem.tooManyLinks ||
widget.problem == ProfileProblem.linkInvalid
? theme.colorScheme.error
: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
for (final (i, link) in _links.indexed)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Expanded(
child: TextField(
controller: link,
keyboardType: TextInputType.url,
autocorrect: false,
decoration: InputDecoration(
hintText: l10n.publicProfileLinkHint,
isDense: true,
),
),
),
IconButton(
tooltip: l10n.publicProfileRemoveLink,
icon: const Icon(LucideIcons.x, size: 18),
onPressed: () => setState(() => _links.removeAt(i).dispose()),
),
],
),
),
if (_links.length < PublicProfile.maxLinks)
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton.icon(
onPressed: () =>
setState(() => _links.add(TextEditingController())),
icon: const Icon(LucideIcons.plus, size: 18),
label: Text(l10n.publicProfileAddLink),
),
),
const SizedBox(height: 16),
Row(
children: [
if (profile != null && !fixed)
TextButton(
onPressed: widget.saving ? null : _remove,
child: Text(l10n.publicProfileRemove),
),
const Spacer(),
FilledButton(
onPressed: widget.saving ? null : _save,
child: Text(l10n.commonSave),
),
],
),
],
);
}
}
/// The avatar, round, with ways to choose another or remove it; once the
/// profile is saved, as an avatar needs a profile to show on.
class const _Picture({
required final PublicProfile? profile,
required final bool saving,
required final String? problem,
}) extends StatelessWidget {
Future<void> _choose(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final bloc = context.read<PublicProfileBloc>();
final picked = await DependenciesScope.of(context).files.pick(
label: l10n.publicProfilePictureType,
extensions: const ['png', 'jpg', 'jpeg', 'webp', 'gif'],
);
if (picked != null) {
bloc.add(PublicProfileEventAvatarChosen(picked.bytes));
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final profile = this.profile;
final url = profile?.avatarUrl;
return Row(
children: [
CircleAvatar(
radius: 32,
backgroundColor: theme.colorScheme.surfaceContainerHighest,
child: ClipOval(
child: url == null
? Icon(
LucideIcons.user,
color: theme.colorScheme.onSurfaceVariant,
)
: Image.network(
url,
width: 64,
height: 64,
fit: BoxFit.cover,
semanticLabel: l10n.publicProfilePicture,
errorBuilder: (context, _, _) => Icon(
LucideIcons.user,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: profile == null
? Text(
l10n.publicProfilePictureLater,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 8,
children: [
TextButton.icon(
onPressed: saving ? null : () => _choose(context),
icon: const Icon(LucideIcons.image, size: 18),
label: Text(l10n.publicProfileChoosePicture),
),
if (url != null)
TextButton(
onPressed: saving
? null
: () => context.read<PublicProfileBloc>().add(
const PublicProfileEventAvatarRemoved(),
),
child: Text(l10n.publicProfileRemovePicture),
),
],
),
if (problem case final problem?)
Text(
problem,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
),
),
],
);
}
}
String _problemText(AppLocalizations l10n, ProfileProblem problem) =>
switch (problem) {
ProfileProblem.handleInvalid => l10n.profileProblemHandleInvalid,
ProfileProblem.handleReserved => l10n.profileProblemHandleReserved,
ProfileProblem.handleTaken => l10n.profileProblemHandleTaken,
ProfileProblem.published => l10n.profileProblemPublished,
ProfileProblem.displayNameTooLong =>
l10n.profileProblemDisplayNameTooLong,
ProfileProblem.bioTooLong => l10n.profileProblemBioTooLong,
ProfileProblem.tooManyLinks => l10n.profileProblemTooManyLinks,
ProfileProblem.linkInvalid => l10n.profileProblemLinkInvalid,
ProfileProblem.avatarInvalid => l10n.profileProblemAvatarInvalid,
};

View file

@ -15,6 +15,7 @@ import 'package:halkyon_learn_flutter/src/feature/note/data/note_deletions.dart'
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
@ -29,6 +30,7 @@ import '../../support/fake_learning_path_gateway.dart';
import '../../support/fake_note_gateway.dart';
import '../../support/fake_path_answer_gateway.dart';
import '../../support/fake_pdf_source_gateway.dart';
import '../../support/fake_profile_gateway.dart';
import '../../support/fake_review_gateway.dart';
import '../../support/fake_settings_gateway.dart';
import '../../support/fake_studyset_gateway.dart';
@ -68,6 +70,7 @@ PersonDependencies personDependencies({Future<void> Function()? onDispose}) {
stepDeletions: StepDeletions(paths),
pdfDeletions: PdfDeletions(pdfSources),
pomodoroRuns: MemoryPomodoroRunStore(),
profile: ProfileRepository(FakeProfileGateway()),
onDispose: onDispose,
);
}

View file

@ -0,0 +1,66 @@
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/data/avatar_image.dart';
/// A PNG [width] × [height], red on its left half, blue on its right.
Future<Uint8List> picture(int width, int height) async {
final recorder = ui.PictureRecorder();
ui.Canvas(recorder)
..drawRect(
ui.Rect.fromLTWH(0, 0, width / 2, height.toDouble()),
ui.Paint()..color = const ui.Color(0xFFFF0000),
)
..drawRect(
ui.Rect.fromLTWH(width / 2, 0, width / 2, height.toDouble()),
ui.Paint()..color = const ui.Color(0xFF0000FF),
);
final image = await recorder.endRecording().toImage(width, height);
final png = await image.toByteData(format: ui.ImageByteFormat.png);
return png!.buffer.asUint8List();
}
Future<ui.Image> decoded(Uint8List png) async {
final codec = await ui.instantiateImageCodec(png);
return (await codec.getNextFrame()).image;
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('a picture becomes its middle square, at most 400 pixels a side, as '
'a PNG', () async {
final wide = await avatarPng(await picture(1200, 600));
final image = await decoded(wide);
expect((image.width, image.height), (400, 400));
expect(wide.sublist(0, 8), [
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a,
]);
// The middle square of a picture red and blue side by side holds both.
final pixels = (await image.toByteData())!;
expect(pixels.getUint8(0), greaterThan(200));
expect(pixels.getUint8(399 * 4 + 2), greaterThan(200));
});
test('a small picture keeps its size', () async {
final image = await decoded(await avatarPng(await picture(100, 160)));
expect((image.width, image.height), (100, 100));
});
test('what is no picture is refused', () async {
await expectLater(
avatarPng(Uint8List.fromList([1, 2, 3])),
throwsA(anything),
);
});
}

View file

@ -0,0 +1,205 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
import 'package:material_ui/material_ui.dart';
import '../../support/fake_auth_gateway.dart';
import '../../support/fake_file_picker.dart';
import '../../support/fake_profile_gateway.dart';
import '../../support/pump_app.dart';
import '../../support/test_dependencies.dart';
void main() {
/// Opens the Profile tab of a person with an identity, whose profile the
/// instance answers through [profiles].
Future<void> showProfile(
WidgetTester tester,
FakeProfileGateway profiles, {
bool identified = true,
FakeFilePicker? files,
}) async {
tester.view.physicalSize = const Size(1000, 1800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
authGateway: FakeAuthGateway(
status: SignedIn(
authUserId: 'github-person',
isIdentified: identified,
),
),
profileGateway: profiles,
files: files,
),
location: RoutePaths.profile,
);
await tester.pumpAndSettle();
}
Finder field(String label) => find.widgetWithText(TextField, label);
testWidgets('a person with an identity writes a public profile and saves '
'it, then sees the address others find them by', (tester) async {
final profiles = FakeProfileGateway();
await showProfile(tester, profiles);
expect(find.text('Public profile'), findsOneWidget);
await tester.enterText(field('Display name'), 'Tizian');
await tester.enterText(field('Handle'), 'raisondetredev');
await tester.enterText(field('Bio'), 'Learning Japanese.');
await tester.tap(find.text('Add link'));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextField, 'https://…'),
'https://halkyon.app',
);
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(find.text('Profile saved.'), findsOneWidget);
expect(
find.text('Others find you as @raisondetredev@halkyon.eu'),
findsOneWidget,
);
expect(profiles.profile!.links, ['https://halkyon.app']);
expect(profiles.profile!.displayName, 'Tizian');
});
testWidgets('a handle someone else has is refused, said under it', (
tester,
) async {
await showProfile(tester, FakeProfileGateway(taken: {'Tizian'}));
await tester.enterText(field('Handle'), 'tizian');
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(find.text('Someone else has this handle.'), findsOneWidget);
});
testWidgets('once its person has published, the handle is fixed and the '
'profile stays', (tester) async {
await showProfile(
tester,
FakeProfileGateway(
profile: const PublicProfile(
handle: 'raisondetredev',
address: 'raisondetredev@halkyon.eu',
handleFixed: true,
),
),
);
expect(
find.text('Fixed since you published: others refer to it.'),
findsOneWidget,
);
expect(
tester.widget<TextField>(field('Handle')).readOnly,
isTrue,
);
expect(find.text('Remove profile'), findsNothing);
});
testWidgets('removing a profile asks first, then frees its handle', (
tester,
) async {
final profiles = FakeProfileGateway(
profile: const PublicProfile(
handle: 'raisondetredev',
address: 'raisondetredev@halkyon.eu',
),
);
await showProfile(tester, profiles);
await tester.tap(find.text('Remove profile'));
await tester.pumpAndSettle();
expect(find.text('Remove your public profile?'), findsOneWidget);
await tester.tap(find.text('Remove profile').last);
await tester.pumpAndSettle();
expect(find.text('Profile removed.'), findsOneWidget);
expect(profiles.profile, isNull);
});
testWidgets('a saved profile takes a picture as its avatar, and gives it '
'up', (tester) async {
final profiles = FakeProfileGateway(
profile: const PublicProfile(
handle: 'raisondetredev',
address: 'raisondetredev@halkyon.eu',
),
);
final files = FakeFilePicker()
..next = (name: 'me.jpg', bytes: Uint8List.fromList([1, 2, 3]));
await showProfile(tester, profiles, files: files);
await tester.tap(find.text('Choose picture'));
await tester.pumpAndSettle();
expect(profiles.avatars.single, [1, 2, 3]);
expect(files.asked.single, contains('jpg'));
expect(find.text('Profile saved.'), findsOneWidget);
await tester.tap(find.text('Remove picture'));
await tester.pumpAndSettle();
expect(profiles.profile!.avatarUrl, isNull);
expect(find.text('Remove picture'), findsNothing);
});
testWidgets('a profile not yet saved has no picture to choose', (
tester,
) async {
await showProfile(tester, FakeProfileGateway());
expect(find.text('Save your profile to add a picture.'), findsOneWidget);
expect(find.text('Choose picture'), findsNothing);
});
testWidgets('an anonymous person is told how to have one', (tester) async {
await showProfile(tester, FakeProfileGateway(), identified: false);
expect(
find.text('Sign in with GitHub to have a public profile and publish.'),
findsOneWidget,
);
expect(find.text('Public profile'), findsNothing);
});
testWidgets('without the instance, it says so and tries again', (
tester,
) async {
final profiles = FakeProfileGateway()..fail = Exception('offline');
await showProfile(tester, profiles);
expect(
find.text("Your public profile can't be reached right now."),
findsOneWidget,
);
profiles.fail = null;
await tester.tap(find.text('Retry'));
await tester.pumpAndSettle();
expect(field('Handle'), findsOneWidget);
});
test(
'the repository leaves blank links out and trims what it saves',
() async {
final profiles = FakeProfileGateway();
await ProfileRepository(profiles).save(
handle: ' tizian ',
displayName: ' Tizian ',
bio: '',
links: ['', ' https://halkyon.app '],
);
expect(profiles.profile!.handle, 'tizian');
expect(profiles.profile!.links, ['https://halkyon.app']);
},
);
}

View file

@ -0,0 +1,103 @@
import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
/// A [ProfileGateway] in memory, on an instance whose account domain is
/// `halkyon.eu`: one profile, refusing the handles others have (whatever
/// their case) and, once its handle is fixed, any other handle.
class FakeProfileGateway({
/// The person's profile; null while they have none.
var PublicProfile? profile,
/// Handles others have.
final Set<String> taken = const {},
}) implements ProfileGateway {
/// When set, every request fails with it.
Exception? fail;
@override
Future<PublicProfile?> mine() async {
if (fail case final error?) {
throw error;
}
return profile;
}
@override
Future<PublicProfile> save({
required String handle,
required String displayName,
required String bio,
required List<String> links,
}) async {
if (fail case final error?) {
throw error;
}
final current = profile;
if (!RegExp(r'^[A-Za-z0-9_]{1,30}$').hasMatch(handle)) {
throw ProfileRefused(ProfileProblem.handleInvalid);
}
if (taken.any((other) => other.toLowerCase() == handle.toLowerCase())) {
throw ProfileRefused(ProfileProblem.handleTaken);
}
if (current != null &&
current.handleFixed &&
current.handle.toLowerCase() != handle.toLowerCase()) {
throw ProfileRefused(ProfileProblem.published);
}
return profile = PublicProfile(
handle: handle,
address: '$handle@halkyon.eu',
displayName: displayName,
bio: bio,
links: links,
handleFixed: current?.handleFixed ?? false,
);
}
/// The avatars set, in order.
final avatars = <Uint8List>[];
@override
Future<PublicProfile> setAvatar(Uint8List png) async {
if (fail case final error?) {
throw error;
}
avatars.add(png);
final current = profile!;
return profile = PublicProfile(
handle: current.handle,
address: current.address,
displayName: current.displayName,
bio: current.bio,
links: current.links,
avatarUrl: 'https://learn.halkyon.eu/avatars/${avatars.length}.png',
handleFixed: current.handleFixed,
);
}
@override
Future<PublicProfile> removeAvatar() async {
final current = profile!;
return profile = PublicProfile(
handle: current.handle,
address: current.address,
displayName: current.displayName,
bio: current.bio,
links: current.links,
handleFixed: current.handleFixed,
);
}
@override
Future<void> remove() async {
if (fail case final error?) {
throw error;
}
if (profile?.handleFixed ?? false) {
throw ProfileRefused(ProfileProblem.published);
}
profile = null;
}
}

View file

@ -42,6 +42,8 @@ import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_gat
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_platform.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_run_store.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_gateway.dart';
@ -66,6 +68,7 @@ import 'fake_path_answer_gateway.dart';
import 'fake_pdf_engine.dart';
import 'fake_pdf_source_gateway.dart';
import 'fake_pomodoro_platform.dart';
import 'fake_profile_gateway.dart';
import 'fake_review_gateway.dart';
import 'fake_settings_gateway.dart';
import 'fake_sound_player.dart';
@ -118,6 +121,7 @@ Dependencies testDependencies({
AppVersion appVersion = const AppVersion(version: '1.0.0', build: '1'),
PomodoroPlatform? pomodoro,
PomodoroRunStore? pomodoroRuns,
ProfileGateway? profileGateway,
SyncOnce? syncOnce,
OpenPerson? openPerson,
}) => Dependencies(
@ -208,6 +212,12 @@ Dependencies testDependencies({
stepDeletions: stepDeletions,
pdfDeletions: pdfDeletions,
pomodoroRuns: pomodoroRuns ?? MemoryPomodoroRunStore(),
// The picture as it is: drawing it anew is real work a widget
// test's fake clock can't wait for (avatar_image_test.dart has it).
profile: ProfileRepository(
profileGateway ?? FakeProfileGateway(),
drawAvatar: (picture) async => picture,
),
onDispose: () async {
await deletions.dispose();
await noteDeletions.dispose();