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>
2026-10-09 20:55:16 +02:00
|
|
|
|
import 'dart:typed_data';
|
|
|
|
|
|
import 'dart:ui' as ui;
|
|
|
|
|
|
|
|
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
feat(app): one Profile for every app, the person's own tab first, then each app's (ADR 0014)
The public profile was already one per person for every service of an
instance (the register, ADR 0016); its app side was Learn's alone. It
moves into halkyon_app: the model, the repository on the module's
endpoints, the form, the avatar drawn on the device and the sheet with
someone's profile. They take the Public prefix (PublicProfileRepository,
BasePublicProfileGateway), as Train's own ProfileRepository holds the
body's figures. AppDependencies gains profiles and files, the picker the
avatar is chosen with, which moves out of Learn too.
HalkyonProfile is every app's Profile screen: the person's own tab first,
the same in every app (the account and the ways they sign in, the app's
own cards, then the public profile), then the app's own tabs, folding into
the title. Learn adds its inbox and Statistics. Train adds Progress, Days
and, where nothing records, Plans, and so opens on the person's tab, no
longer on Progress. Todo, which has no Profile tab, opens it from a row at
the top of Browse, where Todoist keeps the account.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-02 09:44:38 +01:00
|
|
|
|
import 'package:halkyon_app/halkyon_app.dart';
|
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>
2026-10-09 20:55:16 +02:00
|
|
|
|
|
|
|
|
|
|
/// A PNG [width] × [height], red on its left half, blue on its right.
|
|
|
|
|
|
Future<Uint8List> picture(int width, int height) async {
|
|
|
|
|
|
final recorder = ui.PictureRecorder();
|
|
|
|
|
|
ui.Canvas(recorder)
|
|
|
|
|
|
..drawRect(
|
|
|
|
|
|
ui.Rect.fromLTWH(0, 0, width / 2, height.toDouble()),
|
|
|
|
|
|
ui.Paint()..color = const ui.Color(0xFFFF0000),
|
|
|
|
|
|
)
|
|
|
|
|
|
..drawRect(
|
|
|
|
|
|
ui.Rect.fromLTWH(width / 2, 0, width / 2, height.toDouble()),
|
|
|
|
|
|
ui.Paint()..color = const ui.Color(0xFF0000FF),
|
|
|
|
|
|
);
|
|
|
|
|
|
final image = await recorder.endRecording().toImage(width, height);
|
|
|
|
|
|
final png = await image.toByteData(format: ui.ImageByteFormat.png);
|
|
|
|
|
|
return png!.buffer.asUint8List();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<ui.Image> decoded(Uint8List png) async {
|
|
|
|
|
|
final codec = await ui.instantiateImageCodec(png);
|
|
|
|
|
|
return (await codec.getNextFrame()).image;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
|
|
|
|
|
|
|
|
test('a picture becomes its middle square, at most 400 pixels a side, as '
|
|
|
|
|
|
'a PNG', () async {
|
|
|
|
|
|
final wide = await avatarPng(await picture(1200, 600));
|
|
|
|
|
|
final image = await decoded(wide);
|
|
|
|
|
|
|
|
|
|
|
|
expect((image.width, image.height), (400, 400));
|
|
|
|
|
|
expect(wide.sublist(0, 8), [
|
|
|
|
|
|
0x89,
|
|
|
|
|
|
0x50,
|
|
|
|
|
|
0x4e,
|
|
|
|
|
|
0x47,
|
|
|
|
|
|
0x0d,
|
|
|
|
|
|
0x0a,
|
|
|
|
|
|
0x1a,
|
|
|
|
|
|
0x0a,
|
|
|
|
|
|
]);
|
|
|
|
|
|
// The middle square of a picture red and blue side by side holds both.
|
|
|
|
|
|
final pixels = (await image.toByteData())!;
|
|
|
|
|
|
expect(pixels.getUint8(0), greaterThan(200));
|
|
|
|
|
|
expect(pixels.getUint8(399 * 4 + 2), greaterThan(200));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('a small picture keeps its size', () async {
|
|
|
|
|
|
final image = await decoded(await avatarPng(await picture(100, 160)));
|
|
|
|
|
|
|
|
|
|
|
|
expect((image.width, image.height), (100, 100));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('what is no picture is refused', () async {
|
|
|
|
|
|
await expectLater(
|
|
|
|
|
|
avatarPng(Uint8List.fromList([1, 2, 3])),
|
|
|
|
|
|
throwsA(anything),
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|