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
52 changed files with 16609 additions and 3 deletions

View file

@ -156,6 +156,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Where sync stands** is `SyncService.status` (syncing, the last sync that went through, the last failure; since the app started), which `SyncStatusBloc` (`core/sync/`, provided by `PersonApp`) hands to the trouble dot on the Settings cogs (on Home and Profile; no tab carries it), the Sync card in Settings and the banner on Home. Only the last outcome counts: one sync that goes through clears a failure. The Library's Refresh and pulling it down sync now (`syncAndWait`, which a pull's spinner waits on). - **Where sync stands** is `SyncService.status` (syncing, the last sync that went through, the last failure; since the app started), which `SyncStatusBloc` (`core/sync/`, provided by `PersonApp`) hands to the trouble dot on the Settings cogs (on Home and Profile; no tab carries it), the Sync card in Settings and the banner on Home. Only the last outcome counts: one sync that goes through clears a failure. The Library's Refresh and pulling it down sync now (`syncAndWait`, which a pull's spinner waits on).
- **Serverpod's version moves together with `serverpod_offline_sync`**, which allows only a narrow range of Serverpod (v0.0.10: `>=4.0.4 <4.1.0`; v0.0.8 pinned v4.0.0 exactly, which the root `pubspec.yaml` overrode until 2026-10-09). Every package pins Serverpod's packages to one version: upgrade them all together with the sync packages and the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`), after reading the sync package's changelog. Never override a version the sync package doesn't allow without checking what changed in `serverpod_database`, which it builds on. - **Serverpod's version moves together with `serverpod_offline_sync`**, which allows only a narrow range of Serverpod (v0.0.10: `>=4.0.4 <4.1.0`; v0.0.8 pinned v4.0.0 exactly, which the root `pubspec.yaml` overrode until 2026-10-09). Every package pins Serverpod's packages to one version: upgrade them all together with the sync packages and the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`), after reading the sync package's changelog. Never override a version the sync package doesn't allow without checking what changed in `serverpod_database`, which it builds on.
- **The source-code link**: both licences require offering the source of what an instance runs to the people who use it. `instance.info().sourceCodeUrl` names it (the operator's `SOURCE_URL`, else this project's repository) and the app's About dialog shows it next to the licences and every package's notice. Keep it working when changing the dialog or the endpoint. - **The source-code link**: both licences require offering the source of what an instance runs to the people who use it. `instance.info().sourceCodeUrl` names it (the operator's `SOURCE_URL`, else this project's repository) and the app's About dialog shows it next to the licences and every package's notice. Keep it working when changing the dialog or the endpoint.
- **Public profiles** (ADR 0005, stage 1 of the public tier): `public_profile` (`serverOnly`, never sent; the app gets a `Profile`, without the person's id) holds a handle (Mastodon's rules: 1 to 30 letters, digits or underscores, unique whatever their case through `handleKey`, a few reserved for the instance; changeable until the first publish, `firstPublishedAt`), a display name, a bio, up to 4 links and an avatar. `ProfileEndpoint` (`identified` only: a profile is for others to see) reads, saves and removes the caller's own; `ProfilesEndpoint` finds anyone's by handle. Handles are on the account domain (`ACCOUNT_DOMAIN`, else the server's public host; `instance.info().accountDomain`), which can never change once anyone follows a handle on it. An avatar is a PNG of at most 1 MB that the app draws anew from the picture chosen (`avatarPng`, so a photo's metadata never leaves the device), kept in the public storage at `avatars/<SHA-256>.png`, named by its content so its URL tells nothing about whose it is, and deleted once no profile shows it. In the app, the Profile tab holds it as a form, for people with an identity.
- **Deleting a person** goes through `deletePerson` (`lib/src/auth/account.dart`), in one transaction and in ADR 0001 rule 6's order: their sessions end first (a device with a valid session would re-upload everything), then their space goes with all its content through the foreign keys, then the person with their identities. `AccountEndpoint.delete` deletes the caller; the app also calls it when an anonymous account is given up (signed out of, or switched away from to GitHub), since nobody could ever sign into it again. - **Deleting a person** goes through `deletePerson` (`lib/src/auth/account.dart`), in one transaction and in ADR 0001 rule 6's order: their sessions end first (a device with a valid session would re-upload everything), then their space goes with all its content through the foreign keys, then the person with their identities. `AccountEndpoint.delete` deletes the caller; the app also calls it when an anonymous account is given up (signed out of, or switched away from to GitHub), since nobody could ever sign into it again.
- **Pruning**: `PruningFutureCall` deletes anonymous people unused for 90 days, daily, scheduled when the server starts. Scheduling a future call always adds an entry, so the server cancels the previous one by its identifier first. - **Pruning**: `PruningFutureCall` deletes anonymous people unused for 90 days, daily, scheduled when the server starts. Scheduling a future call always adds an entry, so the server cancels the previous one by its identifier first.
- **One person's data per device**: `PersonFolders` wipes every other person's folder when a person's data opens: their sessions ended on this device. Opening waits for the previous person's database to close. - **One person's data per device**: `PersonFolders` wipes every other person's folder when a person's data opens: their sessions ended on this device. Opening waits for the previous person's database to close.

View file

@ -22,6 +22,8 @@ import 'package:halkyon_learn_client/src/protocol/dictionary/dictionary_source.d
as _i8vj64sp; as _i8vj64sp;
import 'package:halkyon_learn_client/src/protocol/instance/instance_info.dart' import 'package:halkyon_learn_client/src/protocol/instance/instance_info.dart'
as _i26u8vl4; as _i26u8vl4;
import 'package:halkyon_learn_client/src/protocol/profile/profile.dart'
as _ihcmr71y;
import 'package:http/http.dart' as _i85jenna; import 'package:http/http.dart' as _i85jenna;
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart' import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
as _iacc; as _iacc;
@ -331,7 +333,8 @@ class EndpointInstance extends _isc.EndpointRef {
String get name => 'instance'; String get name => 'instance';
/// This instance's offer: GitHub sign-in only with the operator's OAuth /// This instance's offer: GitHub sign-in only with the operator's OAuth
/// app, where its source is, and the oldest app build it works with. /// app, where its source is, the oldest app build it works with, and the
/// domain its people's handles are on.
_ida.Future<_i26u8vl4.InstanceInfo> info() => _ida.Future<_i26u8vl4.InstanceInfo> info() =>
caller.callServerEndpoint<_i26u8vl4.InstanceInfo>( caller.callServerEndpoint<_i26u8vl4.InstanceInfo>(
'instance', 'instance',
@ -340,6 +343,96 @@ class EndpointInstance extends _isc.EndpointRef {
); );
} }
/// Your public profile (ADR 0005, rule 2): the handle others find you by,
/// your display name, bio and links. Only for people with an identity: a
/// profile is for others to see.
///
/// The person always comes from the session, never from the client: a
/// profile is only ever the caller's own.
/// {@category Endpoint}
class EndpointProfile extends _isc.EndpointRef {
EndpointProfile(_isc.EndpointCaller caller) : super(caller);
@override
String get name => 'profile';
/// Your public profile; null while you have none.
_ida.Future<_ihcmr71y.Profile?> mine() =>
caller.callServerEndpoint<_ihcmr71y.Profile?>(
'profile',
'mine',
{},
);
/// Saves your public profile, making it if you have none. Throws
/// [ProfileRefusedException] when it can't be saved (see
/// [ProfileRefusal]): a handle someone else has, whatever its case, or a
/// new handle once you have published.
_ida.Future<_ihcmr71y.Profile> save(
String handle,
String displayName,
String bio,
List<String> links,
) => caller.callServerEndpoint<_ihcmr71y.Profile>(
'profile',
'save',
{
'handle': handle,
'displayName': displayName,
'bio': bio,
'links': links,
},
);
/// Shows [png] as your avatar, in place of any before: a PNG of at most
/// 1 MB, which the app draws from the picture chosen. Throws
/// [ProfileRefusedException] for anything else, and [NotFoundException]
/// while you have no profile.
_ida.Future<_ihcmr71y.Profile> setAvatar(_idt.ByteData png) =>
caller.callServerEndpoint<_ihcmr71y.Profile>(
'profile',
'setAvatar',
{'png': png},
);
/// Shows no avatar any more. Throws [NotFoundException] while you have no
/// profile.
_ida.Future<_ihcmr71y.Profile> removeAvatar() =>
caller.callServerEndpoint<_ihcmr71y.Profile>(
'profile',
'removeAvatar',
{},
);
/// Removes your public profile, freeing its handle; nothing when you have
/// none. Throws [ProfileRefusedException] once you have published: others
/// refer to it.
_ida.Future<void> remove() => caller.callServerEndpoint<void>(
'profile',
'remove',
{},
);
}
/// The public profiles of others, found by their handles. Any signed-in
/// person may read them: they are public.
/// {@category Endpoint}
class EndpointProfiles extends _isc.EndpointRef {
EndpointProfiles(_isc.EndpointCaller caller) : super(caller);
@override
String get name => 'profiles';
/// The public profile with [handle], whatever its case. Throws
/// [NotFoundException] when there is none.
_ida.Future<_ihcmr71y.Profile> byHandle(String handle) =>
caller.callServerEndpoint<_ihcmr71y.Profile>(
'profiles',
'byHandle',
{'handle': handle},
);
}
class Modules { class Modules {
Modules(Client client) { Modules(Client client) {
serverpod_auth_idp = _iaic.Caller(client); serverpod_auth_idp = _iaic.Caller(client);
@ -389,6 +482,8 @@ class Client extends _isc.ServerpodClientShared {
gitHubUpgrade = EndpointGitHubUpgrade(this); gitHubUpgrade = EndpointGitHubUpgrade(this);
dictionary = EndpointDictionary(this); dictionary = EndpointDictionary(this);
instance = EndpointInstance(this); instance = EndpointInstance(this);
profile = EndpointProfile(this);
profiles = EndpointProfiles(this);
modules = Modules(this); modules = Modules(this);
} }
@ -408,6 +503,10 @@ class Client extends _isc.ServerpodClientShared {
late final EndpointInstance instance; late final EndpointInstance instance;
late final EndpointProfile profile;
late final EndpointProfiles profiles;
late final Modules modules; late final Modules modules;
@override @override
@ -420,6 +519,8 @@ class Client extends _isc.ServerpodClientShared {
'gitHubUpgrade': gitHubUpgrade, 'gitHubUpgrade': gitHubUpgrade,
'dictionary': dictionary, 'dictionary': dictionary,
'instance': instance, 'instance': instance,
'profile': profile,
'profiles': profiles,
}; };
@override @override

View file

@ -20,12 +20,14 @@ abstract class InstanceInfo
this.gitHubClientId, this.gitHubClientId,
required this.sourceCodeUrl, required this.sourceCodeUrl,
this.minimumAppBuild, this.minimumAppBuild,
this.accountDomain,
}); });
factory InstanceInfo({ factory InstanceInfo({
String? gitHubClientId, String? gitHubClientId,
required String sourceCodeUrl, required String sourceCodeUrl,
int? minimumAppBuild, int? minimumAppBuild,
String? accountDomain,
}) = _InstanceInfoImpl; }) = _InstanceInfoImpl;
factory InstanceInfo.fromJson(Map<String, dynamic> jsonSerialization) { factory InstanceInfo.fromJson(Map<String, dynamic> jsonSerialization) {
@ -33,6 +35,7 @@ abstract class InstanceInfo
gitHubClientId: jsonSerialization['gitHubClientId'] as String?, gitHubClientId: jsonSerialization['gitHubClientId'] as String?,
sourceCodeUrl: jsonSerialization['sourceCodeUrl'] as String, sourceCodeUrl: jsonSerialization['sourceCodeUrl'] as String,
minimumAppBuild: jsonSerialization['minimumAppBuild'] as int?, minimumAppBuild: jsonSerialization['minimumAppBuild'] as int?,
accountDomain: jsonSerialization['accountDomain'] as String?,
); );
} }
@ -49,6 +52,11 @@ abstract class InstanceInfo
/// that predate it: any build. /// that predate it: any build.
int? minimumAppBuild; int? minimumAppBuild;
/// The domain people's handles are on (`halkyon.eu` in
/// `raisondetredev@halkyon.eu`, ADR 0005). Null from servers that predate
/// it.
String? accountDomain;
/// Returns a shallow copy of this [InstanceInfo] /// Returns a shallow copy of this [InstanceInfo]
/// with some or all fields replaced by the given arguments. /// with some or all fields replaced by the given arguments.
@_isc.useResult @_isc.useResult
@ -56,6 +64,7 @@ abstract class InstanceInfo
String? gitHubClientId, String? gitHubClientId,
String? sourceCodeUrl, String? sourceCodeUrl,
int? minimumAppBuild, int? minimumAppBuild,
String? accountDomain,
}); });
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
@ -64,6 +73,7 @@ abstract class InstanceInfo
if (gitHubClientId != null) 'gitHubClientId': gitHubClientId, if (gitHubClientId != null) 'gitHubClientId': gitHubClientId,
'sourceCodeUrl': sourceCodeUrl, 'sourceCodeUrl': sourceCodeUrl,
if (minimumAppBuild != null) 'minimumAppBuild': minimumAppBuild, if (minimumAppBuild != null) 'minimumAppBuild': minimumAppBuild,
if (accountDomain != null) 'accountDomain': accountDomain,
}; };
} }
@ -74,6 +84,7 @@ abstract class InstanceInfo
if (gitHubClientId != null) 'gitHubClientId': gitHubClientId, if (gitHubClientId != null) 'gitHubClientId': gitHubClientId,
'sourceCodeUrl': sourceCodeUrl, 'sourceCodeUrl': sourceCodeUrl,
if (minimumAppBuild != null) 'minimumAppBuild': minimumAppBuild, if (minimumAppBuild != null) 'minimumAppBuild': minimumAppBuild,
if (accountDomain != null) 'accountDomain': accountDomain,
}; };
} }
@ -90,10 +101,12 @@ class _InstanceInfoImpl extends InstanceInfo {
String? gitHubClientId, String? gitHubClientId,
required String sourceCodeUrl, required String sourceCodeUrl,
int? minimumAppBuild, int? minimumAppBuild,
String? accountDomain,
}) : super._( }) : super._(
gitHubClientId: gitHubClientId, gitHubClientId: gitHubClientId,
sourceCodeUrl: sourceCodeUrl, sourceCodeUrl: sourceCodeUrl,
minimumAppBuild: minimumAppBuild, minimumAppBuild: minimumAppBuild,
accountDomain: accountDomain,
); );
/// Returns a shallow copy of this [InstanceInfo] /// Returns a shallow copy of this [InstanceInfo]
@ -104,6 +117,7 @@ class _InstanceInfoImpl extends InstanceInfo {
Object? gitHubClientId = _Undefined, Object? gitHubClientId = _Undefined,
String? sourceCodeUrl, String? sourceCodeUrl,
Object? minimumAppBuild = _Undefined, Object? minimumAppBuild = _Undefined,
Object? accountDomain = _Undefined,
}) { }) {
return InstanceInfo( return InstanceInfo(
gitHubClientId: gitHubClientId is String? gitHubClientId: gitHubClientId is String?
@ -113,6 +127,9 @@ class _InstanceInfoImpl extends InstanceInfo {
minimumAppBuild: minimumAppBuild is int? minimumAppBuild: minimumAppBuild is int?
? minimumAppBuild ? minimumAppBuild
: this.minimumAppBuild, : this.minimumAppBuild,
accountDomain: accountDomain is String?
? accountDomain
: this.accountDomain,
); );
} }
} }

View file

@ -0,0 +1,168 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:halkyon_learn_client/src/protocol/protocol.dart' as _itx5j3f5;
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// A person's public profile as the app shows it (ADR 0005, rule 2): never
/// whose it is, only who they say they are.
abstract class Profile
implements _isc.SerializableModel, _isc.ProtocolSerialization {
Profile._({
required this.handle,
required this.address,
required this.displayName,
required this.bio,
required this.links,
this.avatarUrl,
required this.handleFixed,
});
factory Profile({
required String handle,
required String address,
required String displayName,
required String bio,
required List<String> links,
String? avatarUrl,
required bool handleFixed,
}) = _ProfileImpl;
factory Profile.fromJson(Map<String, dynamic> jsonSerialization) {
return Profile(
handle: jsonSerialization['handle'] as String,
address: jsonSerialization['address'] as String,
displayName: jsonSerialization['displayName'] as String,
bio: jsonSerialization['bio'] as String,
links: _itx5j3f5.Protocol().deserialize<List<String>>(
jsonSerialization['links'],
),
avatarUrl: jsonSerialization['avatarUrl'] as String?,
handleFixed: _isc.BoolJsonExtension.fromJson(
jsonSerialization['handleFixed'],
),
);
}
/// The handle as its person wrote it.
String handle;
/// The handle with the account domain, as the fediverse knows it
/// (`raisondetredev@halkyon.eu`).
String address;
/// The name shown beside the handle; empty for none.
String displayName;
/// A few words about the person; empty for none.
String bio;
/// Pages about the person, in their order.
List<String> links;
/// Where their avatar is, a PNG; null for none.
String? avatarUrl;
/// Whether the handle is fixed, as its person has published.
bool handleFixed;
/// Returns a shallow copy of this [Profile]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
Profile copyWith({
String? handle,
String? address,
String? displayName,
String? bio,
List<String>? links,
String? avatarUrl,
bool? handleFixed,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'Profile',
'handle': handle,
'address': address,
'displayName': displayName,
'bio': bio,
'links': links.toJson(),
if (avatarUrl != null) 'avatarUrl': avatarUrl,
'handleFixed': handleFixed,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'Profile',
'handle': handle,
'address': address,
'displayName': displayName,
'bio': bio,
'links': links.toJson(),
if (avatarUrl != null) 'avatarUrl': avatarUrl,
'handleFixed': handleFixed,
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _Undefined {}
class _ProfileImpl extends Profile {
_ProfileImpl({
required String handle,
required String address,
required String displayName,
required String bio,
required List<String> links,
String? avatarUrl,
required bool handleFixed,
}) : super._(
handle: handle,
address: address,
displayName: displayName,
bio: bio,
links: links,
avatarUrl: avatarUrl,
handleFixed: handleFixed,
);
/// Returns a shallow copy of this [Profile]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
Profile copyWith({
String? handle,
String? address,
String? displayName,
String? bio,
List<String>? links,
Object? avatarUrl = _Undefined,
bool? handleFixed,
}) {
return Profile(
handle: handle ?? this.handle,
address: address ?? this.address,
displayName: displayName ?? this.displayName,
bio: bio ?? this.bio,
links: links ?? this.links.map((e0) => e0).toList(),
avatarUrl: avatarUrl is String? ? avatarUrl : this.avatarUrl,
handleFixed: handleFixed ?? this.handleFixed,
);
}
}

View file

@ -0,0 +1,77 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// Why saving or removing a public profile was refused; nothing changed.
enum ProfileRefusal implements _isc.SerializableModel {
/// The handle isn't 1 to 30 letters (a to z), digits or underscores.
handleInvalid,
/// The handle is kept for the instance (`admin`, `support`, …).
handleReserved,
/// Someone else has the handle, whatever its case.
handleTaken,
/// The person has published: their handle can't change, nor their profile
/// go, any more.
published,
/// The display name is longer than 30 characters.
displayNameTooLong,
/// The bio is longer than 500 characters.
bioTooLong,
/// More than 4 links.
tooManyLinks,
/// A link isn't an absolute HTTP(S) URL of at most 2,000 characters.
linkInvalid,
/// The avatar isn't a PNG of at most 1 MB.
avatarInvalid;
static ProfileRefusal fromJson(String name) {
switch (name) {
case 'handleInvalid':
return ProfileRefusal.handleInvalid;
case 'handleReserved':
return ProfileRefusal.handleReserved;
case 'handleTaken':
return ProfileRefusal.handleTaken;
case 'published':
return ProfileRefusal.published;
case 'displayNameTooLong':
return ProfileRefusal.displayNameTooLong;
case 'bioTooLong':
return ProfileRefusal.bioTooLong;
case 'tooManyLinks':
return ProfileRefusal.tooManyLinks;
case 'linkInvalid':
return ProfileRefusal.linkInvalid;
case 'avatarInvalid':
return ProfileRefusal.avatarInvalid;
default:
throw ArgumentError(
'Value "$name" cannot be converted to "ProfileRefusal"',
);
}
}
@override
String toJson() => name;
@override
String toString() => name;
}

View file

@ -0,0 +1,77 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
import '../profile/profile_refusal.dart' as _i5pl5k19;
/// Saving or removing a public profile was refused; nothing changed.
abstract class ProfileRefusedException
implements
_isc.SerializableException,
_isc.SerializableModel,
_isc.ProtocolSerialization {
ProfileRefusedException._({required this.reason});
factory ProfileRefusedException({required _i5pl5k19.ProfileRefusal reason}) =
_ProfileRefusedExceptionImpl;
factory ProfileRefusedException.fromJson(
Map<String, dynamic> jsonSerialization,
) {
return ProfileRefusedException(
reason: _i5pl5k19.ProfileRefusal.fromJson(
(jsonSerialization['reason'] as String),
),
);
}
/// Why it was refused.
_i5pl5k19.ProfileRefusal reason;
/// Returns a shallow copy of this [ProfileRefusedException]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
ProfileRefusedException copyWith({_i5pl5k19.ProfileRefusal? reason});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'ProfileRefusedException',
'reason': reason.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'ProfileRefusedException',
'reason': reason.toJson(),
};
}
@override
String toString() {
return 'ProfileRefusedException(reason: $reason)';
}
}
class _ProfileRefusedExceptionImpl extends ProfileRefusedException {
_ProfileRefusedExceptionImpl({required _i5pl5k19.ProfileRefusal reason})
: super._(reason: reason);
/// Returns a shallow copy of this [ProfileRefusedException]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
ProfileRefusedException copyWith({_i5pl5k19.ProfileRefusal? reason}) {
return ProfileRefusedException(reason: reason ?? this.reason);
}
}

View file

@ -48,6 +48,9 @@ import 'note/note.dart' as _i9njjhyv;
import 'note/note_revision.dart' as _i6j5dng4; import 'note/note_revision.dart' as _i6j5dng4;
import 'pdf_source/pdf_reading.dart' as _i4l20c7i; import 'pdf_source/pdf_reading.dart' as _i4l20c7i;
import 'pdf_source/pdf_source.dart' as _iz838ro9; import 'pdf_source/pdf_source.dart' as _iz838ro9;
import 'profile/profile.dart' as _ia5j8fch;
import 'profile/profile_refusal.dart' as _i6j6efd1;
import 'profile/profile_refused_exception.dart' as _idvc5e7p;
import 'review/review_log.dart' as _irzjbdqz; import 'review/review_log.dart' as _irzjbdqz;
import 'settings/person_settings.dart' as _irnhx5e5; import 'settings/person_settings.dart' as _irnhx5e5;
import 'studyset/studyset.dart' as _ikbclmae; import 'studyset/studyset.dart' as _ikbclmae;
@ -78,6 +81,9 @@ export 'note/note.dart';
export 'note/note_revision.dart'; export 'note/note_revision.dart';
export 'pdf_source/pdf_reading.dart'; export 'pdf_source/pdf_reading.dart';
export 'pdf_source/pdf_source.dart'; export 'pdf_source/pdf_source.dart';
export 'profile/profile.dart';
export 'profile/profile_refusal.dart';
export 'profile/profile_refused_exception.dart';
export 'review/review_log.dart'; export 'review/review_log.dart';
export 'settings/person_settings.dart'; export 'settings/person_settings.dart';
export 'studyset/studyset.dart'; export 'studyset/studyset.dart';
@ -2044,6 +2050,15 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _iz838ro9.PdfSource) { if (t == _iz838ro9.PdfSource) {
return _iz838ro9.PdfSource.fromJson(data) as T; return _iz838ro9.PdfSource.fromJson(data) as T;
} }
if (t == _ia5j8fch.Profile) {
return _ia5j8fch.Profile.fromJson(data) as T;
}
if (t == _i6j6efd1.ProfileRefusal) {
return _i6j6efd1.ProfileRefusal.fromJson(data) as T;
}
if (t == _idvc5e7p.ProfileRefusedException) {
return _idvc5e7p.ProfileRefusedException.fromJson(data) as T;
}
if (t == _irzjbdqz.ReviewLog) { if (t == _irzjbdqz.ReviewLog) {
return _irzjbdqz.ReviewLog.fromJson(data) as T; return _irzjbdqz.ReviewLog.fromJson(data) as T;
} }
@ -2154,6 +2169,19 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _isc.getType<_iz838ro9.PdfSource?>()) { if (t == _isc.getType<_iz838ro9.PdfSource?>()) {
return (data != null ? _iz838ro9.PdfSource.fromJson(data) : null) as T; return (data != null ? _iz838ro9.PdfSource.fromJson(data) : null) as T;
} }
if (t == _isc.getType<_ia5j8fch.Profile?>()) {
return (data != null ? _ia5j8fch.Profile.fromJson(data) : null) as T;
}
if (t == _isc.getType<_i6j6efd1.ProfileRefusal?>()) {
return (data != null ? _i6j6efd1.ProfileRefusal.fromJson(data) : null)
as T;
}
if (t == _isc.getType<_idvc5e7p.ProfileRefusedException?>()) {
return (data != null
? _idvc5e7p.ProfileRefusedException.fromJson(data)
: null)
as T;
}
if (t == _isc.getType<_irzjbdqz.ReviewLog?>()) { if (t == _isc.getType<_irzjbdqz.ReviewLog?>()) {
return (data != null ? _irzjbdqz.ReviewLog.fromJson(data) : null) as T; return (data != null ? _irzjbdqz.ReviewLog.fromJson(data) : null) as T;
} }
@ -2214,6 +2242,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
) )
as T; as T;
} }
if (t == List<String>) {
return (data as List).map((e) => deserialize<String>(e)).toList() as T;
}
try { try {
return _iaic.Protocol().deserialize<T>(data, t); return _iaic.Protocol().deserialize<T>(data, t);
} on _isc.DeserializationTypeNotFoundException catch (_) {} } on _isc.DeserializationTypeNotFoundException catch (_) {}
@ -2251,6 +2282,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
_i6j5dng4.NoteRevision => 'NoteRevision', _i6j5dng4.NoteRevision => 'NoteRevision',
_i4l20c7i.PdfReading => 'PdfReading', _i4l20c7i.PdfReading => 'PdfReading',
_iz838ro9.PdfSource => 'PdfSource', _iz838ro9.PdfSource => 'PdfSource',
_ia5j8fch.Profile => 'Profile',
_i6j6efd1.ProfileRefusal => 'ProfileRefusal',
_idvc5e7p.ProfileRefusedException => 'ProfileRefusedException',
_irzjbdqz.ReviewLog => 'ReviewLog', _irzjbdqz.ReviewLog => 'ReviewLog',
_irnhx5e5.PersonSettings => 'PersonSettings', _irnhx5e5.PersonSettings => 'PersonSettings',
_ikbclmae.Studyset => 'Studyset', _ikbclmae.Studyset => 'Studyset',
@ -2321,6 +2355,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
return 'PdfReading'; return 'PdfReading';
case _iz838ro9.PdfSource(): case _iz838ro9.PdfSource():
return 'PdfSource'; return 'PdfSource';
case _ia5j8fch.Profile():
return 'Profile';
case _i6j6efd1.ProfileRefusal():
return 'ProfileRefusal';
case _idvc5e7p.ProfileRefusedException():
return 'ProfileRefusedException';
case _irzjbdqz.ReviewLog(): case _irzjbdqz.ReviewLog():
return 'ReviewLog'; return 'ReviewLog';
case _irnhx5e5.PersonSettings(): case _irnhx5e5.PersonSettings():
@ -2432,6 +2472,15 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (dataClassName == 'PdfSource') { if (dataClassName == 'PdfSource') {
return deserialize<_iz838ro9.PdfSource>(data['data']); return deserialize<_iz838ro9.PdfSource>(data['data']);
} }
if (dataClassName == 'Profile') {
return deserialize<_ia5j8fch.Profile>(data['data']);
}
if (dataClassName == 'ProfileRefusal') {
return deserialize<_i6j6efd1.ProfileRefusal>(data['data']);
}
if (dataClassName == 'ProfileRefusedException') {
return deserialize<_idvc5e7p.ProfileRefusedException>(data['data']);
}
if (dataClassName == 'ReviewLog') { if (dataClassName == 'ReviewLog') {
return deserialize<_irzjbdqz.ReviewLog>(data['data']); return deserialize<_irzjbdqz.ReviewLog>(data['data']);
} }

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.", "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", "profileSwitchStay": "Anonym bleiben",
"profileSwitchConfirm": "Wechseln", "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", "settingsTitle": "Einstellungen",
"settingsAccount": "Konto", "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.", "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", "profileSwitchStay": "Stay anonymous",
"profileSwitchConfirm": "Switch", "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", "settingsTitle": "Settings",
"settingsAccount": "Account", "settingsAccount": "Account",

View file

@ -2380,6 +2380,228 @@ abstract class AppLocalizations {
/// **'Switch'** /// **'Switch'**
String get profileSwitchConfirm; 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. /// No description provided for @settingsTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -1397,6 +1397,133 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get profileSwitchConfirm => 'Wechseln'; 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 @override
String get settingsTitle => 'Einstellungen'; String get settingsTitle => 'Einstellungen';

View file

@ -1389,6 +1389,131 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get profileSwitchConfirm => 'Switch'; 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 @override
String get settingsTitle => 'Settings'; 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_platform.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_platforms.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/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/settings_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/sync_settings_gateway.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'; import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
@ -298,6 +300,7 @@ Future<PersonDependencies> _openPerson(
pomodoroRuns: FilePomodoroRunStore( pomodoroRuns: FilePomodoroRunStore(
File(p.join(folder.path, 'pomodoro.json')), File(p.join(folder.path, 'pomodoro.json')),
), ),
profile: ProfileRepository(ServerpodProfileGateway(client)),
onDispose: () async { onDispose: () async {
try { try {
// Deletions first: those still waiting are dropped, those under way // 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_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.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/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/settings/data/settings_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_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'; 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. /// Where this device keeps the person's Pomodoro timer.
required final PomodoroRunStore pomodoroRuns, required final PomodoroRunStore pomodoroRuns,
/// The person's public profile, on the instance.
required final ProfileRepository profile,
final Future<void> Function()? _onDispose, final Future<void> Function()? _onDispose,
}) { }) {
/// Closes what was opened for the person. /// 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/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/github_sign_in_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_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:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -98,7 +99,21 @@ class const _Account() extends StatelessWidget {
Text( Text(
isIdentified ? l10n.profileIdentified : l10n.profileAnonymous, 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,
);
}

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