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:
parent
500ce1a25b
commit
3a328d2bb8
13 changed files with 293 additions and 24 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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})$');
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue