feat(profile): profile photo or seed-illustration avatar

Profiles had only a coloured-initial disc. Now each person can set an
avatar — a real photo OR one of our own seed illustrations (so
pseudonymity stays the default; no photo required).

- ui/avatar.dart: the one-string value scheme carried in the kind:0
  'picture' — a 'data:' photo thumbnail, a 'tane:seed:<glyph>' token, or
  empty. The Nostr ProfileTransport already published 'picture'.
- Photos ride inline as a tiny thumbnail (reuses offerThumbnailDataUri,
  24 KB cap) — no media server, like offer photos.
- avatar_edit.dart: pick/take a photo, choose a seed illustration, or
  remove; ProfileStore stores it; ProfileScreen shows a big editable
  avatar and publishes it.
- PeerAvatar renders photo / illustration / initial fallback.
- ProfileCache gains picture/setPicture; the inbox caches peers' avatars
  alongside their names.
- i18n avatar block (en/es/pt/ast). Tests: value scheme + PeerAvatar
  render modes, store + cache round-trips.

Follow-up: render peers' avatars in chat/market/your-people (thread the
cached picture into PeerAvatar at each call site).
This commit is contained in:
vjrj 2026-07-11 22:21:52 +02:00
parent d28454b010
commit 461bc3cb36
19 changed files with 375 additions and 12 deletions

View file

@ -9,6 +9,8 @@ import '../services/social_account_store.dart';
import '../services/profile_store.dart';
import '../services/social_connection.dart';
import '../services/social_service.dart';
import 'avatar_edit.dart';
import 'peer_avatar.dart';
import 'qr_view.dart';
import 'restart_widget.dart';
import 'theme.dart';
@ -44,6 +46,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
final _name = TextEditingController();
final _about = TextEditingController();
final _g1 = TextEditingController();
String _avatar = '';
bool _loading = true;
bool _saving = false;
bool _switching = false;
@ -62,6 +65,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
_name.text = await widget.profileStore.name();
_about.text = await widget.profileStore.about();
_g1.text = await widget.profileStore.g1();
_avatar = await widget.profileStore.avatar();
final max = await widget.accounts.maxCreated();
final ids = <({int account, String npub})>[];
for (var i = 0; i <= max; i++) {
@ -108,6 +112,11 @@ class _ProfileScreenState extends State<ProfileScreen> {
await _switchTo(next, confirm: false); // creating one is already explicit
}
Future<void> _editAvatar() async {
final result = await showAvatarPicker(context, current: _avatar);
if (result != null && mounted) setState(() => _avatar = result);
}
Future<void> _copyId() async {
final messenger = ScaffoldMessenger.of(context);
final t = context.t;
@ -120,8 +129,8 @@ class _ProfileScreenState extends State<ProfileScreen> {
final messenger = ScaffoldMessenger.of(context);
final t = context.t;
setState(() => _saving = true);
await widget.profileStore
.save(name: _name.text, about: _about.text, g1: _g1.text);
await widget.profileStore.save(
name: _name.text, about: _about.text, g1: _g1.text, avatar: _avatar);
// Best-effort publish over the shared connection so peers see the name.
try {
@ -129,6 +138,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
await session?.profile.publish(
name: _name.text.trim(),
about: _about.text.trim(),
picture: _avatar,
g1: _g1.text.trim(),
);
} catch (_) {
@ -157,6 +167,33 @@ class _ProfileScreenState extends State<ProfileScreen> {
: ListView(
padding: const EdgeInsets.all(20),
children: [
Center(
child: GestureDetector(
key: const Key('profile.avatar'),
onTap: _editAvatar,
child: Stack(
alignment: AlignmentDirectional.bottomEnd,
children: [
PeerAvatar(
pubkey: widget.social.publicKeyHex,
name: _name.text,
picture: _avatar,
radius: 44,
),
Container(
padding: const EdgeInsets.all(5),
decoration: const BoxDecoration(
color: seedGreen,
shape: BoxShape.circle,
),
child: const Icon(Icons.edit,
size: 15, color: Colors.white),
),
],
),
),
),
const SizedBox(height: 20),
_IdentityCard(npub: widget.social.npub, onCopy: _copyId),
const SizedBox(height: 24),
TextField(