feat(profile): a handle in a studyset opens its person's profile

The summary's first block now reads as a studyset's card: its secondary
title, its title, then by @handle; the blocks' second heading is
Material. A licence holder written as a handle of the instance
(@raisondetredev) reads © raisondetredev, the © standing for the @,
and is a link, apart from a name from elsewhere such as Tae Kim's; it
and the author open their public profile in a sheet: avatar, name,
address, bio and links, as the instance's profiles.byHandle gives them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-14 18:14:04 +02:00
commit 3a328d2bb8
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
13 changed files with 293 additions and 24 deletions

View file

@ -471,6 +471,9 @@
"profileSwitchStay": "Anonym bleiben", "profileSwitchStay": "Anonym bleiben",
"profileSwitchConfirm": "Wechseln", "profileSwitchConfirm": "Wechseln",
"publicProfileTitle": "Öffentliches Profil", "publicProfileTitle": "Öffentliches Profil",
"profileSheetTitle": "Profil",
"profileSheetNotFound": "Niemand auf dieser Instanz hat diesen Handle.",
"profileSheetUnreachable": "Das Profil konnte nicht geladen werden. Prüfe deine Verbindung.",
"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.", "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.",
"publicProfileAnonymousHint": "Melde dich mit GitHub an, um ein öffentliches Profil zu haben und zu veröffentlichen.", "publicProfileAnonymousHint": "Melde dich mit GitHub an, um ein öffentliches Profil zu haben und zu veröffentlichen.",
"publicProfileDisplayName": "Anzeigename", "publicProfileDisplayName": "Anzeigename",
@ -1623,7 +1626,7 @@
"exploreNothingMatches": "Nichts Veröffentlichtes passt dazu.", "exploreNothingMatches": "Nichts Veröffentlichtes passt dazu.",
"exploreBy": "von {author}", "exploreBy": "von {author}",
"exploreRelease": "v{version} · {date}", "exploreRelease": "v{version} · {date}",
"exploreHolds": "Inhalt", "exploreMaterial": "Material",
"exploreLatestRelease": "Neueste Veröffentlichung: v{version} · {date}", "exploreLatestRelease": "Neueste Veröffentlichung: v{version} · {date}",
"exploreAddToLibrary": "Zur Bibliothek hinzufügen", "exploreAddToLibrary": "Zur Bibliothek hinzufügen",
"exploreAdd": "Hinzufügen", "exploreAdd": "Hinzufügen",

View file

@ -531,6 +531,9 @@
"profileSwitchStay": "Stay anonymous", "profileSwitchStay": "Stay anonymous",
"profileSwitchConfirm": "Switch", "profileSwitchConfirm": "Switch",
"publicProfileTitle": "Public profile", "publicProfileTitle": "Public profile",
"profileSheetTitle": "Profile",
"profileSheetNotFound": "Nobody on this instance has this handle.",
"profileSheetUnreachable": "The profile couldn’t be loaded. Check your connection.",
"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.", "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.",
"publicProfileAnonymousHint": "Sign in with GitHub to have a public profile and publish.", "publicProfileAnonymousHint": "Sign in with GitHub to have a public profile and publish.",
"publicProfileDisplayName": "Display name", "publicProfileDisplayName": "Display name",
@ -1904,7 +1907,7 @@
"@exploreBy": {"placeholders": {"author": {"type": "String"}}}, "@exploreBy": {"placeholders": {"author": {"type": "String"}}},
"exploreRelease": "v{version} · {date}", "exploreRelease": "v{version} · {date}",
"@exploreRelease": {"placeholders": {"version": {"type": "int"}, "date": {"type": "String"}}}, "@exploreRelease": {"placeholders": {"version": {"type": "int"}, "date": {"type": "String"}}},
"exploreHolds": "What it holds", "exploreMaterial": "Material",
"exploreLatestRelease": "Latest release: v{version} · {date}", "exploreLatestRelease": "Latest release: v{version} · {date}",
"@exploreLatestRelease": {"placeholders": {"version": {"type": "int"}, "date": {"type": "String"}}}, "@exploreLatestRelease": {"placeholders": {"version": {"type": "int"}, "date": {"type": "String"}}},
"exploreAddToLibrary": "Add to library", "exploreAddToLibrary": "Add to library",

View file

@ -2836,6 +2836,24 @@ abstract class AppLocalizations {
/// **'Public profile'** /// **'Public profile'**
String get publicProfileTitle; String get publicProfileTitle;
/// No description provided for @profileSheetTitle.
///
/// In en, this message translates to:
/// **'Profile'**
String get profileSheetTitle;
/// 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 @profileSheetUnreachable.
///
/// In en, this message translates to:
/// **'The profile couldn’t be loaded. Check your connection.'**
String get profileSheetUnreachable;
/// No description provided for @publicProfileIntro. /// No description provided for @publicProfileIntro.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -9603,11 +9621,11 @@ abstract class AppLocalizations {
/// **'v{version} · {date}'** /// **'v{version} · {date}'**
String exploreRelease(int version, String date); String exploreRelease(int version, String date);
/// No description provided for @exploreHolds. /// No description provided for @exploreMaterial.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'What it holds'** /// **'Material'**
String get exploreHolds; String get exploreMaterial;
/// No description provided for @exploreLatestRelease. /// No description provided for @exploreLatestRelease.
/// ///

View file

@ -1739,6 +1739,17 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get publicProfileTitle => 'Öffentliches Profil'; String get publicProfileTitle => 'Öffentliches Profil';
@override
String get profileSheetTitle => 'Profil';
@override
String get profileSheetNotFound =>
'Niemand auf dieser Instanz hat diesen Handle.';
@override
String get profileSheetUnreachable =>
'Das Profil konnte nicht geladen werden. Prüfe deine Verbindung.';
@override @override
String get publicProfileIntro => 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.'; '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.';
@ -6045,7 +6056,7 @@ class AppLocalizationsDe extends AppLocalizations {
} }
@override @override
String get exploreHolds => 'Inhalt'; String get exploreMaterial => 'Material';
@override @override
String exploreLatestRelease(int version, String date) { String exploreLatestRelease(int version, String date) {

View file

@ -1724,6 +1724,16 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get publicProfileTitle => 'Public profile'; String get publicProfileTitle => 'Public profile';
@override
String get profileSheetTitle => 'Profile';
@override
String get profileSheetNotFound => 'Nobody on this instance has this handle.';
@override
String get profileSheetUnreachable =>
'The profile couldn’t be loaded. Check your connection.';
@override @override
String get publicProfileIntro => 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.'; '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.';
@ -5992,7 +6002,7 @@ class AppLocalizationsEn extends AppLocalizations {
} }
@override @override
String get exploreHolds => 'What it holds'; String get exploreMaterial => 'Material';
@override @override
String exploreLatestRelease(int version, String date) { String exploreLatestRelease(int version, String date) {

View file

@ -4,10 +4,12 @@ import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
import 'package:halkyon_learn_flutter/src/core/l10n/content_languages.dart'; import 'package:halkyon_learn_flutter/src/core/l10n/content_languages.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart'; import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart'; import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
import 'package:halkyon_learn_flutter/src/core/widget/title_with_kicker.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/bloc/explore_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/explore/bloc/explore_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart'; import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/widget/explore_screen.dart'; import 'package:halkyon_learn_flutter/src/feature/explore/widget/explore_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/widget/feedback_threads.dart'; import 'package:halkyon_learn_flutter/src/feature/feedback/widget/feedback_threads.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/widget/profile_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/license_footer.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/widget/license_footer.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -48,7 +50,9 @@ class const _Summary({required final ExploreStudyset studyset})
]; ];
return BlocBuilder<ExploreBloc, ExploreState>( return BlocBuilder<ExploreBloc, ExploreState>(
builder: (context, state) => SheetScaffold( builder: (context, state) => SheetScaffold(
title: studyset.title, // Its title is in the first block, under its secondary title, as
// the library's cards set it.
title: '',
body: Column( body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@ -64,16 +68,23 @@ class const _Summary({required final ExploreStudyset studyset})
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (studyset.secondaryTitle case final secondary?) TitleWithKicker(
Text( title: studyset.title,
secondary, kicker: studyset.secondaryTitle,
style: theme.textTheme.labelLarge?.copyWith( titleStyle: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
// The author, whose profile it opens.
InkWell(
onTap: () => showProfileOf(context, studyset.handle),
child: Text(
l10n.exploreBy(authorLine(studyset)),
style: muted?.copyWith(
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
), ),
), ),
Text(
l10n.exploreBy(authorLine(studyset)),
style: muted,
), ),
if (studyset.basedOn case final original?) if (studyset.basedOn case final original?)
Text(l10n.exploreBasedOn(original), style: muted), Text(l10n.exploreBasedOn(original), style: muted),
@ -100,7 +111,7 @@ class const _Summary({required final ExploreStudyset studyset})
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(l10n.exploreHolds, style: theme.textTheme.titleSmall), Text(l10n.exploreMaterial, style: theme.textTheme.titleSmall),
const SizedBox(height: 8), const SizedBox(height: 8),
if (languages.isNotEmpty) if (languages.isNotEmpty)
Text( Text(

View file

@ -8,6 +8,10 @@ abstract interface class ProfileGateway() {
/// The person's public profile; null while they have none. /// The person's public profile; null while they have none.
Future<PublicProfile?> mine(); 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. /// Saves the person's public profile, making it if they have none.
/// Throws [ProfileRefused] when the instance refuses it. /// Throws [ProfileRefused] when the instance refuses it.
Future<PublicProfile> save({ Future<PublicProfile> save({

View file

@ -15,6 +15,10 @@ class ProfileRepository(
/// The person's public profile; null while they have none. /// The person's public profile; null while they have none.
Future<PublicProfile?> mine() => _gateway.mine(); 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 /// Saves the person's public profile, blank links left out. Throws
/// [ProfileRefused] when the instance refuses it. /// [ProfileRefused] when the instance refuses it.
Future<PublicProfile> save({ Future<PublicProfile> save({

View file

@ -25,6 +25,15 @@ class ServerpodProfileGateway(final protocol.Client _client)
), ),
); );
@override
Future<PublicProfile?> byHandle(String handle) async {
try {
return _profile(await _client.profiles.byHandle(handle));
} on protocol.NotFoundException {
return null;
}
}
@override @override
Future<ProfileProblem?> checkHandle(String handle) async => Future<ProfileProblem?> checkHandle(String handle) async =>
switch (await _client.profile.checkHandle(handle)) { switch (await _client.profile.checkHandle(handle)) {

View file

@ -0,0 +1,120 @@
import 'dart:async';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.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';
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 = PersonScope.of(context).profile;
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 = AppLocalizations.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,
),
onTap: () => unawaited(
launchUrl(
Uri.parse(link.contains('://') ? link : 'https://$link'),
),
),
),
],
],
);
}
}

View file

@ -1,3 +1,4 @@
import 'package:halkyon_learn_flutter/src/feature/profile/widget/profile_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/license_link.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/model/license_link.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -36,11 +37,16 @@ class const LicenseFooter({
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
); );
// The year sits between the symbol and the holder, as copyright notices // The year sits between the symbol and the holder, as copyright notices
// put it, and either can stand alone. // put it, and either can stand alone. A holder written as a handle
// (`@raisondetredev`) is a person of the instance: named without the
// `@`, which the © already stands for, and a link to their profile,
// so they read apart from a name from elsewhere (Tae Kim).
final handle = _handle.firstMatch(holder)?.group(1);
final credit = [ final credit = [
if (year != null) '$year', if (year != null) '$year',
if (holder.isNotEmpty) holder, if (holder.isNotEmpty && handle == null) holder,
].join(' '); ].join(' ');
final link = muted?.copyWith(color: theme.colorScheme.primary);
final url = license.isEmpty ? null : licenseUrl(license); final url = license.isEmpty ? null : licenseUrl(license);
// Two lines at most. // Two lines at most.
return Padding( return Padding(
@ -50,8 +56,17 @@ class const LicenseFooter({
child: Text.rich( child: Text.rich(
TextSpan( TextSpan(
children: [ children: [
if (credit.isNotEmpty) TextSpan(text: '© $credit'), if (credit.isNotEmpty || handle != null)
if (credit.isNotEmpty && license.isNotEmpty) TextSpan(text: '© $credit${credit.isEmpty ? '' : ' '}'),
if (handle != null)
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: InkWell(
onTap: () => showProfileOf(context, handle),
child: Text(handle, style: link),
),
),
if ((credit.isNotEmpty || handle != null) && license.isNotEmpty)
const TextSpan(text: ' · '), const TextSpan(text: ' · '),
if (license.isNotEmpty) if (license.isNotEmpty)
if (url == null) if (url == null)
@ -61,10 +76,7 @@ class const LicenseFooter({
alignment: PlaceholderAlignment.middle, alignment: PlaceholderAlignment.middle,
child: InkWell( child: InkWell(
onTap: () => launchUrl(url), onTap: () => launchUrl(url),
child: Text( child: Text('$license ↗', style: link),
'$license ↗',
style: muted?.copyWith(color: theme.colorScheme.primary),
),
), ),
), ),
], ],
@ -77,3 +89,7 @@ class const LicenseFooter({
); );
} }
} }
/// A holder written as a handle of the instance, `@` and Mastodon's
/// letters, digits and underscores; the handle without its `@`.
final _handle = RegExp(r'^@([A-Za-z0-9_]{1,30})$');

View file

@ -9,6 +9,7 @@ import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart'; import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart'; import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
@ -17,6 +18,7 @@ import 'package:material_ui/material_ui.dart';
import '../../support/fake_auth_gateway.dart'; import '../../support/fake_auth_gateway.dart';
import '../../support/fake_explore_gateway.dart'; import '../../support/fake_explore_gateway.dart';
import '../../support/fake_profile_gateway.dart';
import '../../support/fake_studyset_gateway.dart'; import '../../support/fake_studyset_gateway.dart';
import '../../support/pump_app.dart'; import '../../support/pump_app.dart';
import '../../support/test_dependencies.dart'; import '../../support/test_dependencies.dart';
@ -91,6 +93,7 @@ void main() {
WidgetTester tester, WidgetTester tester,
FakeExploreGateway explore, { FakeExploreGateway explore, {
bool identified = true, bool identified = true,
FakeProfileGateway? profiles,
}) async { }) async {
tester.view.physicalSize = const Size(900, 1400); tester.view.physicalSize = const Size(900, 1400);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
@ -106,6 +109,7 @@ void main() {
), ),
studysetGateway: studysets, studysetGateway: studysets,
exploreGateway: explore, exploreGateway: explore,
profileGateway: profiles,
), ),
location: RoutePaths.explore, location: RoutePaths.explore,
); );
@ -242,6 +246,51 @@ void main() {
); );
}); });
testWidgets('a licence holder of the instance is named by their handle, '
"without the @, opening their profile; one from elsewhere isn't", (
tester,
) async {
final japanese = published('Japanese');
await showExplore(
tester,
FakeExploreGateway(
published: [
ExploreStudyset(
publicId: japanese.publicId,
location: japanese.location,
handle: 'alice',
version: 3,
releasedAt: japanese.releasedAt,
title: 'Japanese',
description: japanese.description,
license: 'CC-BY-SA-4.0',
licenseHolder: '@alice',
),
],
),
profiles: FakeProfileGateway(
others: {
'alice': const PublicProfile(
handle: 'alice',
address: 'alice@halkyon.eu',
displayName: 'Alice',
bio: 'Learning Japanese.',
),
},
),
);
await tester.tap(find.text('Japanese'));
await tester.pumpAndSettle();
expect(find.textContaining('@alice', findRichText: true), findsWidgets);
expect(find.text('alice'), findsOneWidget);
await tester.tap(find.text('alice'));
await tester.pumpAndSettle();
expect(find.text('Learning Japanese.'), findsOneWidget);
expect(find.text('@alice@halkyon.eu'), findsOneWidget);
});
testWidgets('a summary shows all of it, and adds it to the library as ' testWidgets('a summary shows all of it, and adds it to the library as '
'a copy, opened from there', (tester) async { 'a copy, opened from there', (tester) async {
await showExplore( await showExplore(

View file

@ -12,7 +12,18 @@ class FakeProfileGateway({
/// Handles others have. /// Handles others have.
final Set<String> taken = const {}, final Set<String> taken = const {},
/// Others' profiles, by their handles.
final Map<String, PublicProfile> others = const {},
}) implements ProfileGateway { }) implements ProfileGateway {
@override
Future<PublicProfile?> byHandle(String handle) async {
if (fail case final error?) {
throw error;
}
return others[handle.toLowerCase()];
}
/// When set, every request fails with it. /// When set, every request fails with it.
Exception? fail; Exception? fail;