From 461bc3cb3637157ef40cc823f03bc1c9fef6ff23 Mon Sep 17 00:00:00 2001 From: vjrj Date: Sat, 11 Jul 2026 22:21:52 +0200 Subject: [PATCH] feat(profile): profile photo or seed-illustration avatar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:' 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). --- apps/app_seeds/lib/i18n/ast.i18n.json | 6 ++ apps/app_seeds/lib/i18n/en.i18n.json | 6 ++ apps/app_seeds/lib/i18n/es.i18n.json | 6 ++ apps/app_seeds/lib/i18n/pt.i18n.json | 6 ++ apps/app_seeds/lib/i18n/strings.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_ast.g.dart | 18 ++++ apps/app_seeds/lib/i18n/strings_en.g.dart | 26 ++++++ apps/app_seeds/lib/i18n/strings_es.g.dart | 18 ++++ apps/app_seeds/lib/i18n/strings_pt.g.dart | 18 ++++ .../app_seeds/lib/services/inbox_service.dart | 16 +++- .../app_seeds/lib/services/profile_cache.dart | 14 +++ .../app_seeds/lib/services/profile_store.dart | 8 ++ apps/app_seeds/lib/ui/avatar.dart | 40 +++++++++ apps/app_seeds/lib/ui/avatar_edit.dart | 90 +++++++++++++++++++ apps/app_seeds/lib/ui/peer_avatar.dart | 35 +++++++- apps/app_seeds/lib/ui/profile_screen.dart | 41 ++++++++- .../test/services/profile_cache_test.dart | 8 ++ .../test/services/profile_store_test.dart | 6 ++ apps/app_seeds/test/ui/peer_avatar_test.dart | 21 +++++ 19 files changed, 375 insertions(+), 12 deletions(-) create mode 100644 apps/app_seeds/lib/ui/avatar.dart create mode 100644 apps/app_seeds/lib/ui/avatar_edit.dart diff --git a/apps/app_seeds/lib/i18n/ast.i18n.json b/apps/app_seeds/lib/i18n/ast.i18n.json index e94ee39..c95aeab 100644 --- a/apps/app_seeds/lib/i18n/ast.i18n.json +++ b/apps/app_seeds/lib/i18n/ast.i18n.json @@ -1,4 +1,10 @@ { + "avatar": { + "title": "La to semeya o avatar", + "fromPhoto": "Facer o escoyer una semeya", + "illustration": "O escueyi un dibuxu", + "remove": "Quitar" + }, "seedSaving": { "title": "Guardar la so semiente", "subtitle": "Lo que fai falta pa caltener la variedá fiel", diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index b6182e1..994bbb8 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -1,4 +1,10 @@ { + "avatar": { + "title": "Your photo or avatar", + "fromPhoto": "Take or choose a photo", + "illustration": "Or pick an illustration", + "remove": "Remove" + }, "seedSaving": { "title": "Saving its seed", "subtitle": "What it takes to keep the variety true", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index c7ce0ca..88e26f5 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -1,4 +1,10 @@ { + "avatar": { + "title": "Tu foto o avatar", + "fromPhoto": "Hacer o elegir una foto", + "illustration": "O elige un dibujo", + "remove": "Quitar" + }, "seedSaving": { "title": "Conservar su semilla", "subtitle": "Lo que hace falta para mantener la variedad fiel", diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index c514e1d..fc10428 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -1,4 +1,10 @@ { + "avatar": { + "title": "A tua foto ou avatar", + "fromPhoto": "Tirar ou escolher uma foto", + "illustration": "Ou escolhe um desenho", + "remove": "Remover" + }, "seedSaving": { "title": "Guardar a sua semente", "subtitle": "O que é preciso para manter a variedade fiel", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index ae8dd2a..efa6566 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 4 -/// Strings: 2016 (504 per locale) +/// Strings: 2032 (508 per locale) /// -/// Built on 2026-07-11 at 12:07 UTC +/// Built on 2026-07-11 at 20:16 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_ast.g.dart b/apps/app_seeds/lib/i18n/strings_ast.g.dart index f7b6490..6e90aed 100644 --- a/apps/app_seeds/lib/i18n/strings_ast.g.dart +++ b/apps/app_seeds/lib/i18n/strings_ast.g.dart @@ -39,6 +39,7 @@ class TranslationsAst extends Translations with BaseTranslations? meta}) => TranslationsAst(meta: meta ?? this.$meta); // Translations + @override late final _Translations$avatar$ast avatar = _Translations$avatar$ast._(_root); @override late final _Translations$seedSaving$ast seedSaving = _Translations$seedSaving$ast._(_root); @override late final _Translations$calendar$ast calendar = _Translations$calendar$ast._(_root); @override late final _Translations$app$ast app = _Translations$app$ast._(_root); @@ -85,6 +86,19 @@ class TranslationsAst extends Translations with BaseTranslations 'La to semeya o avatar'; + @override String get fromPhoto => 'Facer o escoyer una semeya'; + @override String get illustration => 'O escueyi un dibuxu'; + @override String get remove => 'Quitar'; +} + // Path: seedSaving class _Translations$seedSaving$ast extends Translations$seedSaving$en { _Translations$seedSaving$ast._(TranslationsAst root) : this._root = root, super.internal(root); @@ -1294,6 +1308,10 @@ class _Translations$intro$slides$plantare$ast extends Translations$intro$slides$ extension on TranslationsAst { dynamic _flatMapFunction(String path) { return switch (path) { + 'avatar.title' => 'La to semeya o avatar', + 'avatar.fromPhoto' => 'Facer o escoyer una semeya', + 'avatar.illustration' => 'O escueyi un dibuxu', + 'avatar.remove' => 'Quitar', 'seedSaving.title' => 'Guardar la so semiente', 'seedSaving.subtitle' => 'Lo que fai falta pa caltener la variedá fiel', 'seedSaving.lifeCycle' => 'Ciclu', diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index d7ee735..0326197 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -40,6 +40,7 @@ class Translations with BaseTranslations { Translations $copyWith({TranslationMetadata? meta}) => Translations(meta: meta ?? this.$meta); // Translations + late final Translations$avatar$en avatar = Translations$avatar$en.internal(_root); late final Translations$seedSaving$en seedSaving = Translations$seedSaving$en.internal(_root); late final Translations$calendar$en calendar = Translations$calendar$en.internal(_root); late final Translations$app$en app = Translations$app$en.internal(_root); @@ -86,6 +87,27 @@ class Translations with BaseTranslations { late final Translations$sale$en sale = Translations$sale$en.internal(_root); } +// Path: avatar +class Translations$avatar$en { + Translations$avatar$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Your photo or avatar' + String get title => 'Your photo or avatar'; + + /// en: 'Take or choose a photo' + String get fromPhoto => 'Take or choose a photo'; + + /// en: 'Or pick an illustration' + String get illustration => 'Or pick an illustration'; + + /// en: 'Remove' + String get remove => 'Remove'; +} + // Path: seedSaving class Translations$seedSaving$en { Translations$seedSaving$en.internal(this._root); @@ -2289,6 +2311,10 @@ class Translations$intro$slides$plantare$en { extension on Translations { dynamic _flatMapFunction(String path) { return switch (path) { + 'avatar.title' => 'Your photo or avatar', + 'avatar.fromPhoto' => 'Take or choose a photo', + 'avatar.illustration' => 'Or pick an illustration', + 'avatar.remove' => 'Remove', 'seedSaving.title' => 'Saving its seed', 'seedSaving.subtitle' => 'What it takes to keep the variety true', 'seedSaving.lifeCycle' => 'Cycle', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 0ecc6a3..712e6f7 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -39,6 +39,7 @@ class TranslationsEs extends Translations with BaseTranslations? meta}) => TranslationsEs(meta: meta ?? this.$meta); // Translations + @override late final _Translations$avatar$es avatar = _Translations$avatar$es._(_root); @override late final _Translations$seedSaving$es seedSaving = _Translations$seedSaving$es._(_root); @override late final _Translations$calendar$es calendar = _Translations$calendar$es._(_root); @override late final _Translations$app$es app = _Translations$app$es._(_root); @@ -85,6 +86,19 @@ class TranslationsEs extends Translations with BaseTranslations 'Tu foto o avatar'; + @override String get fromPhoto => 'Hacer o elegir una foto'; + @override String get illustration => 'O elige un dibujo'; + @override String get remove => 'Quitar'; +} + // Path: seedSaving class _Translations$seedSaving$es extends Translations$seedSaving$en { _Translations$seedSaving$es._(TranslationsEs root) : this._root = root, super.internal(root); @@ -1296,6 +1310,10 @@ class _Translations$intro$slides$plantare$es extends Translations$intro$slides$p extension on TranslationsEs { dynamic _flatMapFunction(String path) { return switch (path) { + 'avatar.title' => 'Tu foto o avatar', + 'avatar.fromPhoto' => 'Hacer o elegir una foto', + 'avatar.illustration' => 'O elige un dibujo', + 'avatar.remove' => 'Quitar', 'seedSaving.title' => 'Conservar su semilla', 'seedSaving.subtitle' => 'Lo que hace falta para mantener la variedad fiel', 'seedSaving.lifeCycle' => 'Ciclo', diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index 589644e..36cb37a 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -39,6 +39,7 @@ class TranslationsPt extends Translations with BaseTranslations? meta}) => TranslationsPt(meta: meta ?? this.$meta); // Translations + @override late final _Translations$avatar$pt avatar = _Translations$avatar$pt._(_root); @override late final _Translations$seedSaving$pt seedSaving = _Translations$seedSaving$pt._(_root); @override late final _Translations$calendar$pt calendar = _Translations$calendar$pt._(_root); @override late final _Translations$app$pt app = _Translations$app$pt._(_root); @@ -85,6 +86,19 @@ class TranslationsPt extends Translations with BaseTranslations 'A tua foto ou avatar'; + @override String get fromPhoto => 'Tirar ou escolher uma foto'; + @override String get illustration => 'Ou escolhe um desenho'; + @override String get remove => 'Remover'; +} + // Path: seedSaving class _Translations$seedSaving$pt extends Translations$seedSaving$en { _Translations$seedSaving$pt._(TranslationsPt root) : this._root = root, super.internal(root); @@ -1293,6 +1307,10 @@ class _Translations$intro$slides$plantare$pt extends Translations$intro$slides$p extension on TranslationsPt { dynamic _flatMapFunction(String path) { return switch (path) { + 'avatar.title' => 'A tua foto ou avatar', + 'avatar.fromPhoto' => 'Tirar ou escolher uma foto', + 'avatar.illustration' => 'Ou escolhe um desenho', + 'avatar.remove' => 'Remover', 'seedSaving.title' => 'Guardar a sua semente', 'seedSaving.subtitle' => 'O que é preciso para manter a variedade fiel', 'seedSaving.lifeCycle' => 'Ciclo', diff --git a/apps/app_seeds/lib/services/inbox_service.dart b/apps/app_seeds/lib/services/inbox_service.dart index 274d579..832360a 100644 --- a/apps/app_seeds/lib/services/inbox_service.dart +++ b/apps/app_seeds/lib/services/inbox_service.dart @@ -117,12 +117,20 @@ class InboxService { final cache = _profileCache; final session = _session; if (cache == null || session == null) return; - if (await cache.name(peerPubkey) != null) return; // already known + // Fetch once we're missing either the name or the avatar for this peer. + final haveName = await cache.name(peerPubkey) != null; + final havePic = await cache.picture(peerPubkey) != null; + if (haveName && havePic) return; try { final profile = await session.profile.fetch(peerPubkey); - if (profile != null && profile.name.isNotEmpty) { - await cache.setName(peerPubkey, profile.name); - if (!_changes.isClosed) _changes.add(null); // re-render with the name + if (profile == null) return; + if (profile.name.isNotEmpty) await cache.setName(peerPubkey, profile.name); + if (profile.picture.isNotEmpty) { + await cache.setPicture(peerPubkey, profile.picture); + } + if ((profile.name.isNotEmpty || profile.picture.isNotEmpty) && + !_changes.isClosed) { + _changes.add(null); // re-render with the name/avatar } } catch (_) { // best effort — the short key shows meanwhile diff --git a/apps/app_seeds/lib/services/profile_cache.dart b/apps/app_seeds/lib/services/profile_cache.dart index a9977fc..b997bd3 100644 --- a/apps/app_seeds/lib/services/profile_cache.dart +++ b/apps/app_seeds/lib/services/profile_cache.dart @@ -14,6 +14,10 @@ class ProfileCache { final SecretStore _store; final String _prefix; + // Avatars peers have published (kind:0 `picture`) live under a parallel key + // namespace, so a name and an avatar are cached independently. + String get _picPrefix => '${_prefix.substring(0, _prefix.length - 5)}picture.'; + /// The cached name for [pubkeyHex], or null if none is known. Future name(String pubkeyHex) async { final value = await _store.read('$_prefix$pubkeyHex'); @@ -22,6 +26,16 @@ class ProfileCache { Future setName(String pubkeyHex, String name) => _store.write('$_prefix$pubkeyHex', name.trim()); + + /// The cached avatar for [pubkeyHex] (a `data:` photo or `tane:seed:` + /// token), or null if none is known. + Future picture(String pubkeyHex) async { + final value = await _store.read('$_picPrefix$pubkeyHex'); + return (value == null || value.isEmpty) ? null : value; + } + + Future setPicture(String pubkeyHex, String picture) => + _store.write('$_picPrefix$pubkeyHex', picture.trim()); } /// A compact, human-ish rendering of a public key when no name is known yet. diff --git a/apps/app_seeds/lib/services/profile_store.dart b/apps/app_seeds/lib/services/profile_store.dart index 970ba8c..d16abd5 100644 --- a/apps/app_seeds/lib/services/profile_store.dart +++ b/apps/app_seeds/lib/services/profile_store.dart @@ -17,6 +17,7 @@ class ProfileStore { String get _nameKey => '${_base}name'; String get _aboutKey => '${_base}about'; String get _g1Key => '${_base}g1'; + String get _avatarKey => '${_base}avatar'; Future name() async => (await _store.read(_nameKey)) ?? ''; Future about() async => (await _store.read(_aboutKey)) ?? ''; @@ -24,13 +25,20 @@ class ProfileStore { /// Your Ğ1 (Duniter) address, if you chose to share one. Future g1() async => (await _store.read(_g1Key)) ?? ''; + /// Your avatar: either a `data:image/jpeg;base64,…` photo thumbnail or a + /// `tane:seed:` illustration token (see `ui/avatar.dart`). Empty for + /// the default coloured-initial disc. Published as the kind:0 `picture`. + Future avatar() async => (await _store.read(_avatarKey)) ?? ''; + Future save({ required String name, required String about, String g1 = '', + String avatar = '', }) async { await _store.write(_nameKey, name.trim()); await _store.write(_aboutKey, about.trim()); await _store.write(_g1Key, g1.trim()); + await _store.write(_avatarKey, avatar.trim()); } } diff --git a/apps/app_seeds/lib/ui/avatar.dart b/apps/app_seeds/lib/ui/avatar.dart new file mode 100644 index 0000000..ee3c570 --- /dev/null +++ b/apps/app_seeds/lib/ui/avatar.dart @@ -0,0 +1,40 @@ +import 'seed_glyph.dart'; + +/// A profile avatar is carried in one string (stored locally and published as +/// the NIP-01 kind:0 `picture`), in one of three shapes: +/// - `data:image/jpeg;base64,…` — a tiny photo thumbnail (rides inline, no +/// media server, like offer photos); +/// - `tane:seed:` — one of our own seed illustrations (for people who'd +/// rather not put a real photo — pseudonymity stays the default); +/// - empty — the deterministic coloured disc with the name's initial. +const avatarGlyphPrefix = 'tane:seed:'; + +/// The seed illustrations offered as ready-made avatars. Names are the stable +/// storage key (appended to [avatarGlyphPrefix]); the glyphs already ship in +/// [SeedGlyphs]. Order is the picker order. +const avatarGlyphNames = [ + 'jars', + 'sack', + 'jar', + 'scattered', + 'pouring', + 'mug', + 'bigSpoon', + 'smallSpoon', +]; + +/// The glyph char for an avatar illustration [name], or null if unknown. +String? avatarGlyphChar(String name) => switch (name) { + 'jars' => SeedGlyphs.jars, + 'sack' => SeedGlyphs.sack, + 'jar' => SeedGlyphs.jar, + 'scattered' => SeedGlyphs.scattered, + 'pouring' => SeedGlyphs.pouring, + 'mug' => SeedGlyphs.mug, + 'bigSpoon' => SeedGlyphs.bigSpoon, + 'smallSpoon' => SeedGlyphs.smallSpoon, + _ => null, + }; + +/// The token that selects illustration [name] (e.g. `tane:seed:jars`). +String avatarGlyphToken(String name) => '$avatarGlyphPrefix$name'; diff --git a/apps/app_seeds/lib/ui/avatar_edit.dart b/apps/app_seeds/lib/ui/avatar_edit.dart new file mode 100644 index 0000000..8a839b5 --- /dev/null +++ b/apps/app_seeds/lib/ui/avatar_edit.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; + +import '../i18n/strings.g.dart'; +import '../services/offer_thumbnail.dart' show offerThumbnailDataUri; +import 'avatar.dart'; +import 'photo_pick.dart'; +import 'seed_glyph.dart'; +import 'theme.dart'; + +/// Lets the user choose a profile avatar: take/pick a photo (shrunk to a tiny +/// inline thumbnail), pick one of our seed illustrations, or remove it. +/// +/// Returns the new avatar value (`data:` thumbnail or `tane:seed:`), +/// an empty string to clear it, or null when cancelled. +Future showAvatarPicker( + BuildContext context, { + required String current, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (sheetContext) { + final t = sheetContext.t; + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t.avatar.title, + style: Theme.of(sheetContext).textTheme.titleMedium), + const SizedBox(height: 8), + ListTile( + key: const Key('avatar.fromPhoto'), + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.add_a_photo_outlined, + color: seedGreen), + title: Text(t.avatar.fromPhoto), + onTap: () async { + final bytes = await pickPhoto(sheetContext); + if (bytes == null) return; + final uri = offerThumbnailDataUri(bytes, maxBytes: 24000); + if (sheetContext.mounted) { + Navigator.of(sheetContext).pop(uri ?? ''); + } + }, + ), + const SizedBox(height: 6), + Text(t.avatar.illustration, + style: const TextStyle(color: seedMuted, fontSize: 13)), + const SizedBox(height: 10), + Wrap( + spacing: 14, + runSpacing: 14, + children: [ + for (final name in avatarGlyphNames) + InkWell( + key: Key('avatar.glyph.$name'), + borderRadius: BorderRadius.circular(40), + onTap: () => Navigator.of(sheetContext) + .pop(avatarGlyphToken(name)), + child: CircleAvatar( + radius: 24, + backgroundColor: seedGreen, + child: SeedGlyph(avatarGlyphChar(name)!, + size: 26, color: Colors.white), + ), + ), + ], + ), + if (current.isNotEmpty) ...[ + const SizedBox(height: 8), + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton.icon( + key: const Key('avatar.remove'), + onPressed: () => Navigator.of(sheetContext).pop(''), + icon: const Icon(Icons.delete_outline, size: 18), + label: Text(t.avatar.remove), + ), + ), + ], + ], + ), + ), + ); + }, + ); +} diff --git a/apps/app_seeds/lib/ui/peer_avatar.dart b/apps/app_seeds/lib/ui/peer_avatar.dart index fae2854..702395f 100644 --- a/apps/app_seeds/lib/ui/peer_avatar.dart +++ b/apps/app_seeds/lib/ui/peer_avatar.dart @@ -1,23 +1,50 @@ import 'package:flutter/material.dart'; -/// A small round avatar for a chat peer. We have no profile photos yet, so it's -/// a deterministic disc coloured from the peer's pubkey, showing the first -/// letter of their [name] when known (else a person glyph). Same pubkey → same -/// colour on every device, so the peer is visually recognizable. +import '../services/offer_thumbnail.dart' show decodeDataUri; +import 'avatar.dart'; +import 'seed_glyph.dart'; + +/// A small round avatar for a person. When they've set an avatar ([picture] — a +/// `data:` photo thumbnail or a `tane:seed:` illustration) it's shown; +/// otherwise it falls back to a deterministic disc coloured from their [pubkey], +/// with the first letter of their [name] (else a person glyph). Same pubkey → +/// same colour on every device, so a person stays visually recognizable. class PeerAvatar extends StatelessWidget { const PeerAvatar({ required this.pubkey, this.name, + this.picture, this.radius = 14, super.key, }); final String pubkey; final String? name; + + /// The person's chosen avatar value; null/empty → the coloured-initial disc. + final String? picture; final double radius; @override Widget build(BuildContext context) { + final pic = picture?.trim() ?? ''; + + if (pic.startsWith('data:')) { + final bytes = decodeDataUri(pic); + if (bytes != null) { + return CircleAvatar(radius: radius, backgroundImage: MemoryImage(bytes)); + } + } else if (pic.startsWith(avatarGlyphPrefix)) { + final glyph = avatarGlyphChar(pic.substring(avatarGlyphPrefix.length)); + if (glyph != null) { + return CircleAvatar( + radius: radius, + backgroundColor: peerAvatarColor(pubkey), + child: SeedGlyph(glyph, size: radius * 1.15, color: Colors.white), + ); + } + } + final letter = _initial(name); return CircleAvatar( radius: radius, diff --git a/apps/app_seeds/lib/ui/profile_screen.dart b/apps/app_seeds/lib/ui/profile_screen.dart index 54b429c..6231c03 100644 --- a/apps/app_seeds/lib/ui/profile_screen.dart +++ b/apps/app_seeds/lib/ui/profile_screen.dart @@ -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 { 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 { _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 { await _switchTo(next, confirm: false); // creating one is already explicit } + Future _editAvatar() async { + final result = await showAvatarPicker(context, current: _avatar); + if (result != null && mounted) setState(() => _avatar = result); + } + Future _copyId() async { final messenger = ScaffoldMessenger.of(context); final t = context.t; @@ -120,8 +129,8 @@ class _ProfileScreenState extends State { 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 { 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 { : 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( diff --git a/apps/app_seeds/test/services/profile_cache_test.dart b/apps/app_seeds/test/services/profile_cache_test.dart index cd62c7b..a14b8d7 100644 --- a/apps/app_seeds/test/services/profile_cache_test.dart +++ b/apps/app_seeds/test/services/profile_cache_test.dart @@ -15,6 +15,14 @@ void main() { expect(await cache.name('abc'), 'Alicia'); }); + test('picture caches independently of name', () async { + final cache = ProfileCache(InMemorySecretStore()); + expect(await cache.picture('abc'), isNull); + await cache.setPicture('abc', ' tane:seed:jars '); + expect(await cache.picture('abc'), 'tane:seed:jars'); + expect(await cache.name('abc'), isNull); // name untouched + }); + test('shortPubkey abbreviates long keys, keeps short ones', () { expect(shortPubkey('abcdef1234567890'), 'abcdef…7890'); expect(shortPubkey('short'), 'short'); diff --git a/apps/app_seeds/test/services/profile_store_test.dart b/apps/app_seeds/test/services/profile_store_test.dart index 6e9b56f..86faf4e 100644 --- a/apps/app_seeds/test/services/profile_store_test.dart +++ b/apps/app_seeds/test/services/profile_store_test.dart @@ -17,4 +17,10 @@ void main() { expect(await store.name(), 'Alicia'); expect(await store.about(), 'tomate rosa'); }); + + test('avatar defaults empty and round-trips', () async { + expect(await store.avatar(), ''); + await store.save(name: 'A', about: '', avatar: 'tane:seed:jars'); + expect(await store.avatar(), 'tane:seed:jars'); + }); } diff --git a/apps/app_seeds/test/ui/peer_avatar_test.dart b/apps/app_seeds/test/ui/peer_avatar_test.dart index ba902df..c56a2a9 100644 --- a/apps/app_seeds/test/ui/peer_avatar_test.dart +++ b/apps/app_seeds/test/ui/peer_avatar_test.dart @@ -1,8 +1,20 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/ui/avatar.dart'; import 'package:tane/ui/peer_avatar.dart'; +import 'package:tane/ui/seed_glyph.dart'; void main() { + group('avatar value scheme', () { + test('every offered illustration maps to a glyph; unknown → null', () { + for (final name in avatarGlyphNames) { + expect(avatarGlyphChar(name), isNotNull, reason: name); + } + expect(avatarGlyphChar('nope'), isNull); + expect(avatarGlyphToken('jars'), 'tane:seed:jars'); + }); + }); + group('peerAvatarColor', () { test('is deterministic for the same pubkey', () { expect(peerAvatarColor('ab' * 32), peerAvatarColor('ab' * 32)); @@ -34,5 +46,14 @@ void main() { expect(find.byIcon(Icons.person_outline), findsOneWidget); expect(find.byType(Text), findsNothing); }); + + testWidgets('renders a seed illustration when the avatar is a token', ( + tester, + ) async { + await tester.pumpWidget(host(const PeerAvatar( + pubkey: 'aa', name: 'rosa', picture: 'tane:seed:jars'))); + expect(find.byType(SeedGlyph), findsOneWidget); + expect(find.text('R'), findsNothing); // the initial isn't used + }); }); }