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:
parent
74c7edaecd
commit
ec56819fbf
19 changed files with 375 additions and 12 deletions
|
|
@ -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": {
|
"seedSaving": {
|
||||||
"title": "Guardar la so semiente",
|
"title": "Guardar la so semiente",
|
||||||
"subtitle": "Lo que fai falta pa caltener la variedá fiel",
|
"subtitle": "Lo que fai falta pa caltener la variedá fiel",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
{
|
{
|
||||||
|
"avatar": {
|
||||||
|
"title": "Your photo or avatar",
|
||||||
|
"fromPhoto": "Take or choose a photo",
|
||||||
|
"illustration": "Or pick an illustration",
|
||||||
|
"remove": "Remove"
|
||||||
|
},
|
||||||
"seedSaving": {
|
"seedSaving": {
|
||||||
"title": "Saving its seed",
|
"title": "Saving its seed",
|
||||||
"subtitle": "What it takes to keep the variety true",
|
"subtitle": "What it takes to keep the variety true",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
{
|
{
|
||||||
|
"avatar": {
|
||||||
|
"title": "Tu foto o avatar",
|
||||||
|
"fromPhoto": "Hacer o elegir una foto",
|
||||||
|
"illustration": "O elige un dibujo",
|
||||||
|
"remove": "Quitar"
|
||||||
|
},
|
||||||
"seedSaving": {
|
"seedSaving": {
|
||||||
"title": "Conservar su semilla",
|
"title": "Conservar su semilla",
|
||||||
"subtitle": "Lo que hace falta para mantener la variedad fiel",
|
"subtitle": "Lo que hace falta para mantener la variedad fiel",
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
"seedSaving": {
|
||||||
"title": "Guardar a sua semente",
|
"title": "Guardar a sua semente",
|
||||||
"subtitle": "O que é preciso para manter a variedade fiel",
|
"subtitle": "O que é preciso para manter a variedade fiel",
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 4
|
/// 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
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// ignore_for_file: type=lint, unused_import
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ class TranslationsAst extends Translations with BaseTranslations<AppLocale, Tran
|
||||||
TranslationsAst $copyWith({TranslationMetadata<AppLocale, Translations>? meta}) => TranslationsAst(meta: meta ?? this.$meta);
|
TranslationsAst $copyWith({TranslationMetadata<AppLocale, Translations>? meta}) => TranslationsAst(meta: meta ?? this.$meta);
|
||||||
|
|
||||||
// Translations
|
// 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$seedSaving$ast seedSaving = _Translations$seedSaving$ast._(_root);
|
||||||
@override late final _Translations$calendar$ast calendar = _Translations$calendar$ast._(_root);
|
@override late final _Translations$calendar$ast calendar = _Translations$calendar$ast._(_root);
|
||||||
@override late final _Translations$app$ast app = _Translations$app$ast._(_root);
|
@override late final _Translations$app$ast app = _Translations$app$ast._(_root);
|
||||||
|
|
@ -85,6 +86,19 @@ class TranslationsAst extends Translations with BaseTranslations<AppLocale, Tran
|
||||||
@override late final _Translations$sale$ast sale = _Translations$sale$ast._(_root);
|
@override late final _Translations$sale$ast sale = _Translations$sale$ast._(_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Path: avatar
|
||||||
|
class _Translations$avatar$ast extends Translations$avatar$en {
|
||||||
|
_Translations$avatar$ast._(TranslationsAst root) : this._root = root, super.internal(root);
|
||||||
|
|
||||||
|
final TranslationsAst _root; // ignore: unused_field
|
||||||
|
|
||||||
|
// Translations
|
||||||
|
@override String get title => '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
|
// Path: seedSaving
|
||||||
class _Translations$seedSaving$ast extends Translations$seedSaving$en {
|
class _Translations$seedSaving$ast extends Translations$seedSaving$en {
|
||||||
_Translations$seedSaving$ast._(TranslationsAst root) : this._root = root, super.internal(root);
|
_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 {
|
extension on TranslationsAst {
|
||||||
dynamic _flatMapFunction(String path) {
|
dynamic _flatMapFunction(String path) {
|
||||||
return switch (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.title' => 'Guardar la so semiente',
|
||||||
'seedSaving.subtitle' => 'Lo que fai falta pa caltener la variedá fiel',
|
'seedSaving.subtitle' => 'Lo que fai falta pa caltener la variedá fiel',
|
||||||
'seedSaving.lifeCycle' => 'Ciclu',
|
'seedSaving.lifeCycle' => 'Ciclu',
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
|
||||||
Translations $copyWith({TranslationMetadata<AppLocale, Translations>? meta}) => Translations(meta: meta ?? this.$meta);
|
Translations $copyWith({TranslationMetadata<AppLocale, Translations>? meta}) => Translations(meta: meta ?? this.$meta);
|
||||||
|
|
||||||
// Translations
|
// 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$seedSaving$en seedSaving = Translations$seedSaving$en.internal(_root);
|
||||||
late final Translations$calendar$en calendar = Translations$calendar$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);
|
late final Translations$app$en app = Translations$app$en.internal(_root);
|
||||||
|
|
@ -86,6 +87,27 @@ class Translations with BaseTranslations<AppLocale, Translations> {
|
||||||
late final Translations$sale$en sale = Translations$sale$en.internal(_root);
|
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
|
// Path: seedSaving
|
||||||
class Translations$seedSaving$en {
|
class Translations$seedSaving$en {
|
||||||
Translations$seedSaving$en.internal(this._root);
|
Translations$seedSaving$en.internal(this._root);
|
||||||
|
|
@ -2289,6 +2311,10 @@ class Translations$intro$slides$plantare$en {
|
||||||
extension on Translations {
|
extension on Translations {
|
||||||
dynamic _flatMapFunction(String path) {
|
dynamic _flatMapFunction(String path) {
|
||||||
return switch (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.title' => 'Saving its seed',
|
||||||
'seedSaving.subtitle' => 'What it takes to keep the variety true',
|
'seedSaving.subtitle' => 'What it takes to keep the variety true',
|
||||||
'seedSaving.lifeCycle' => 'Cycle',
|
'seedSaving.lifeCycle' => 'Cycle',
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
|
||||||
TranslationsEs $copyWith({TranslationMetadata<AppLocale, Translations>? meta}) => TranslationsEs(meta: meta ?? this.$meta);
|
TranslationsEs $copyWith({TranslationMetadata<AppLocale, Translations>? meta}) => TranslationsEs(meta: meta ?? this.$meta);
|
||||||
|
|
||||||
// Translations
|
// 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$seedSaving$es seedSaving = _Translations$seedSaving$es._(_root);
|
||||||
@override late final _Translations$calendar$es calendar = _Translations$calendar$es._(_root);
|
@override late final _Translations$calendar$es calendar = _Translations$calendar$es._(_root);
|
||||||
@override late final _Translations$app$es app = _Translations$app$es._(_root);
|
@override late final _Translations$app$es app = _Translations$app$es._(_root);
|
||||||
|
|
@ -85,6 +86,19 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
|
||||||
@override late final _Translations$sale$es sale = _Translations$sale$es._(_root);
|
@override late final _Translations$sale$es sale = _Translations$sale$es._(_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Path: avatar
|
||||||
|
class _Translations$avatar$es extends Translations$avatar$en {
|
||||||
|
_Translations$avatar$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
||||||
|
|
||||||
|
final TranslationsEs _root; // ignore: unused_field
|
||||||
|
|
||||||
|
// Translations
|
||||||
|
@override String get title => '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
|
// Path: seedSaving
|
||||||
class _Translations$seedSaving$es extends Translations$seedSaving$en {
|
class _Translations$seedSaving$es extends Translations$seedSaving$en {
|
||||||
_Translations$seedSaving$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
_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 {
|
extension on TranslationsEs {
|
||||||
dynamic _flatMapFunction(String path) {
|
dynamic _flatMapFunction(String path) {
|
||||||
return switch (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.title' => 'Conservar su semilla',
|
||||||
'seedSaving.subtitle' => 'Lo que hace falta para mantener la variedad fiel',
|
'seedSaving.subtitle' => 'Lo que hace falta para mantener la variedad fiel',
|
||||||
'seedSaving.lifeCycle' => 'Ciclo',
|
'seedSaving.lifeCycle' => 'Ciclo',
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ class TranslationsPt extends Translations with BaseTranslations<AppLocale, Trans
|
||||||
TranslationsPt $copyWith({TranslationMetadata<AppLocale, Translations>? meta}) => TranslationsPt(meta: meta ?? this.$meta);
|
TranslationsPt $copyWith({TranslationMetadata<AppLocale, Translations>? meta}) => TranslationsPt(meta: meta ?? this.$meta);
|
||||||
|
|
||||||
// Translations
|
// 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$seedSaving$pt seedSaving = _Translations$seedSaving$pt._(_root);
|
||||||
@override late final _Translations$calendar$pt calendar = _Translations$calendar$pt._(_root);
|
@override late final _Translations$calendar$pt calendar = _Translations$calendar$pt._(_root);
|
||||||
@override late final _Translations$app$pt app = _Translations$app$pt._(_root);
|
@override late final _Translations$app$pt app = _Translations$app$pt._(_root);
|
||||||
|
|
@ -85,6 +86,19 @@ class TranslationsPt extends Translations with BaseTranslations<AppLocale, Trans
|
||||||
@override late final _Translations$sale$pt sale = _Translations$sale$pt._(_root);
|
@override late final _Translations$sale$pt sale = _Translations$sale$pt._(_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Path: avatar
|
||||||
|
class _Translations$avatar$pt extends Translations$avatar$en {
|
||||||
|
_Translations$avatar$pt._(TranslationsPt root) : this._root = root, super.internal(root);
|
||||||
|
|
||||||
|
final TranslationsPt _root; // ignore: unused_field
|
||||||
|
|
||||||
|
// Translations
|
||||||
|
@override String get title => '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
|
// Path: seedSaving
|
||||||
class _Translations$seedSaving$pt extends Translations$seedSaving$en {
|
class _Translations$seedSaving$pt extends Translations$seedSaving$en {
|
||||||
_Translations$seedSaving$pt._(TranslationsPt root) : this._root = root, super.internal(root);
|
_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 {
|
extension on TranslationsPt {
|
||||||
dynamic _flatMapFunction(String path) {
|
dynamic _flatMapFunction(String path) {
|
||||||
return switch (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.title' => 'Guardar a sua semente',
|
||||||
'seedSaving.subtitle' => 'O que é preciso para manter a variedade fiel',
|
'seedSaving.subtitle' => 'O que é preciso para manter a variedade fiel',
|
||||||
'seedSaving.lifeCycle' => 'Ciclo',
|
'seedSaving.lifeCycle' => 'Ciclo',
|
||||||
|
|
|
||||||
|
|
@ -117,12 +117,20 @@ class InboxService {
|
||||||
final cache = _profileCache;
|
final cache = _profileCache;
|
||||||
final session = _session;
|
final session = _session;
|
||||||
if (cache == null || session == null) return;
|
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 {
|
try {
|
||||||
final profile = await session.profile.fetch(peerPubkey);
|
final profile = await session.profile.fetch(peerPubkey);
|
||||||
if (profile != null && profile.name.isNotEmpty) {
|
if (profile == null) return;
|
||||||
await cache.setName(peerPubkey, profile.name);
|
if (profile.name.isNotEmpty) await cache.setName(peerPubkey, profile.name);
|
||||||
if (!_changes.isClosed) _changes.add(null); // re-render with the 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 (_) {
|
} catch (_) {
|
||||||
// best effort — the short key shows meanwhile
|
// best effort — the short key shows meanwhile
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ class ProfileCache {
|
||||||
final SecretStore _store;
|
final SecretStore _store;
|
||||||
final String _prefix;
|
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.
|
/// The cached name for [pubkeyHex], or null if none is known.
|
||||||
Future<String?> name(String pubkeyHex) async {
|
Future<String?> name(String pubkeyHex) async {
|
||||||
final value = await _store.read('$_prefix$pubkeyHex');
|
final value = await _store.read('$_prefix$pubkeyHex');
|
||||||
|
|
@ -22,6 +26,16 @@ class ProfileCache {
|
||||||
|
|
||||||
Future<void> setName(String pubkeyHex, String name) =>
|
Future<void> setName(String pubkeyHex, String name) =>
|
||||||
_store.write('$_prefix$pubkeyHex', name.trim());
|
_store.write('$_prefix$pubkeyHex', name.trim());
|
||||||
|
|
||||||
|
/// The cached avatar for [pubkeyHex] (a `data:` photo or `tane:seed:<glyph>`
|
||||||
|
/// token), or null if none is known.
|
||||||
|
Future<String?> picture(String pubkeyHex) async {
|
||||||
|
final value = await _store.read('$_picPrefix$pubkeyHex');
|
||||||
|
return (value == null || value.isEmpty) ? null : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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.
|
/// A compact, human-ish rendering of a public key when no name is known yet.
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ class ProfileStore {
|
||||||
String get _nameKey => '${_base}name';
|
String get _nameKey => '${_base}name';
|
||||||
String get _aboutKey => '${_base}about';
|
String get _aboutKey => '${_base}about';
|
||||||
String get _g1Key => '${_base}g1';
|
String get _g1Key => '${_base}g1';
|
||||||
|
String get _avatarKey => '${_base}avatar';
|
||||||
|
|
||||||
Future<String> name() async => (await _store.read(_nameKey)) ?? '';
|
Future<String> name() async => (await _store.read(_nameKey)) ?? '';
|
||||||
Future<String> about() async => (await _store.read(_aboutKey)) ?? '';
|
Future<String> about() async => (await _store.read(_aboutKey)) ?? '';
|
||||||
|
|
@ -24,13 +25,20 @@ class ProfileStore {
|
||||||
/// Your Ğ1 (Duniter) address, if you chose to share one.
|
/// Your Ğ1 (Duniter) address, if you chose to share one.
|
||||||
Future<String> g1() async => (await _store.read(_g1Key)) ?? '';
|
Future<String> g1() async => (await _store.read(_g1Key)) ?? '';
|
||||||
|
|
||||||
|
/// Your avatar: either a `data:image/jpeg;base64,…` photo thumbnail or a
|
||||||
|
/// `tane:seed:<glyph>` illustration token (see `ui/avatar.dart`). Empty for
|
||||||
|
/// the default coloured-initial disc. Published as the kind:0 `picture`.
|
||||||
|
Future<String> avatar() async => (await _store.read(_avatarKey)) ?? '';
|
||||||
|
|
||||||
Future<void> save({
|
Future<void> save({
|
||||||
required String name,
|
required String name,
|
||||||
required String about,
|
required String about,
|
||||||
String g1 = '',
|
String g1 = '',
|
||||||
|
String avatar = '',
|
||||||
}) async {
|
}) async {
|
||||||
await _store.write(_nameKey, name.trim());
|
await _store.write(_nameKey, name.trim());
|
||||||
await _store.write(_aboutKey, about.trim());
|
await _store.write(_aboutKey, about.trim());
|
||||||
await _store.write(_g1Key, g1.trim());
|
await _store.write(_g1Key, g1.trim());
|
||||||
|
await _store.write(_avatarKey, avatar.trim());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
40
apps/app_seeds/lib/ui/avatar.dart
Normal file
40
apps/app_seeds/lib/ui/avatar.dart
Normal file
|
|
@ -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:<name>` — 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 = <String>[
|
||||||
|
'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';
|
||||||
90
apps/app_seeds/lib/ui/avatar_edit.dart
Normal file
90
apps/app_seeds/lib/ui/avatar_edit.dart
Normal file
|
|
@ -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:<glyph>`),
|
||||||
|
/// an empty string to clear it, or null when cancelled.
|
||||||
|
Future<String?> showAvatarPicker(
|
||||||
|
BuildContext context, {
|
||||||
|
required String current,
|
||||||
|
}) {
|
||||||
|
return showModalBottomSheet<String>(
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,23 +1,50 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// A small round avatar for a chat peer. We have no profile photos yet, so it's
|
import '../services/offer_thumbnail.dart' show decodeDataUri;
|
||||||
/// a deterministic disc coloured from the peer's pubkey, showing the first
|
import 'avatar.dart';
|
||||||
/// letter of their [name] when known (else a person glyph). Same pubkey → same
|
import 'seed_glyph.dart';
|
||||||
/// colour on every device, so the peer is visually recognizable.
|
|
||||||
|
/// A small round avatar for a person. When they've set an avatar ([picture] — a
|
||||||
|
/// `data:` photo thumbnail or a `tane:seed:<glyph>` 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 {
|
class PeerAvatar extends StatelessWidget {
|
||||||
const PeerAvatar({
|
const PeerAvatar({
|
||||||
required this.pubkey,
|
required this.pubkey,
|
||||||
this.name,
|
this.name,
|
||||||
|
this.picture,
|
||||||
this.radius = 14,
|
this.radius = 14,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String pubkey;
|
final String pubkey;
|
||||||
final String? name;
|
final String? name;
|
||||||
|
|
||||||
|
/// The person's chosen avatar value; null/empty → the coloured-initial disc.
|
||||||
|
final String? picture;
|
||||||
final double radius;
|
final double radius;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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);
|
final letter = _initial(name);
|
||||||
return CircleAvatar(
|
return CircleAvatar(
|
||||||
radius: radius,
|
radius: radius,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import '../services/social_account_store.dart';
|
||||||
import '../services/profile_store.dart';
|
import '../services/profile_store.dart';
|
||||||
import '../services/social_connection.dart';
|
import '../services/social_connection.dart';
|
||||||
import '../services/social_service.dart';
|
import '../services/social_service.dart';
|
||||||
|
import 'avatar_edit.dart';
|
||||||
|
import 'peer_avatar.dart';
|
||||||
import 'qr_view.dart';
|
import 'qr_view.dart';
|
||||||
import 'restart_widget.dart';
|
import 'restart_widget.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
|
@ -44,6 +46,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||||
final _name = TextEditingController();
|
final _name = TextEditingController();
|
||||||
final _about = TextEditingController();
|
final _about = TextEditingController();
|
||||||
final _g1 = TextEditingController();
|
final _g1 = TextEditingController();
|
||||||
|
String _avatar = '';
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
bool _switching = false;
|
bool _switching = false;
|
||||||
|
|
@ -62,6 +65,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||||
_name.text = await widget.profileStore.name();
|
_name.text = await widget.profileStore.name();
|
||||||
_about.text = await widget.profileStore.about();
|
_about.text = await widget.profileStore.about();
|
||||||
_g1.text = await widget.profileStore.g1();
|
_g1.text = await widget.profileStore.g1();
|
||||||
|
_avatar = await widget.profileStore.avatar();
|
||||||
final max = await widget.accounts.maxCreated();
|
final max = await widget.accounts.maxCreated();
|
||||||
final ids = <({int account, String npub})>[];
|
final ids = <({int account, String npub})>[];
|
||||||
for (var i = 0; i <= max; i++) {
|
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
|
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 {
|
Future<void> _copyId() async {
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
final t = context.t;
|
final t = context.t;
|
||||||
|
|
@ -120,8 +129,8 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
final t = context.t;
|
final t = context.t;
|
||||||
setState(() => _saving = true);
|
setState(() => _saving = true);
|
||||||
await widget.profileStore
|
await widget.profileStore.save(
|
||||||
.save(name: _name.text, about: _about.text, g1: _g1.text);
|
name: _name.text, about: _about.text, g1: _g1.text, avatar: _avatar);
|
||||||
|
|
||||||
// Best-effort publish over the shared connection so peers see the name.
|
// Best-effort publish over the shared connection so peers see the name.
|
||||||
try {
|
try {
|
||||||
|
|
@ -129,6 +138,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||||
await session?.profile.publish(
|
await session?.profile.publish(
|
||||||
name: _name.text.trim(),
|
name: _name.text.trim(),
|
||||||
about: _about.text.trim(),
|
about: _about.text.trim(),
|
||||||
|
picture: _avatar,
|
||||||
g1: _g1.text.trim(),
|
g1: _g1.text.trim(),
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
|
@ -157,6 +167,33 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||||
: ListView(
|
: ListView(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
children: [
|
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),
|
_IdentityCard(npub: widget.social.npub, onCopy: _copyId),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
TextField(
|
TextField(
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,14 @@ void main() {
|
||||||
expect(await cache.name('abc'), 'Alicia');
|
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', () {
|
test('shortPubkey abbreviates long keys, keeps short ones', () {
|
||||||
expect(shortPubkey('abcdef1234567890'), 'abcdef…7890');
|
expect(shortPubkey('abcdef1234567890'), 'abcdef…7890');
|
||||||
expect(shortPubkey('short'), 'short');
|
expect(shortPubkey('short'), 'short');
|
||||||
|
|
|
||||||
|
|
@ -17,4 +17,10 @@ void main() {
|
||||||
expect(await store.name(), 'Alicia');
|
expect(await store.name(), 'Alicia');
|
||||||
expect(await store.about(), 'tomate rosa');
|
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');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,20 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.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/peer_avatar.dart';
|
||||||
|
import 'package:tane/ui/seed_glyph.dart';
|
||||||
|
|
||||||
void main() {
|
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', () {
|
group('peerAvatarColor', () {
|
||||||
test('is deterministic for the same pubkey', () {
|
test('is deterministic for the same pubkey', () {
|
||||||
expect(peerAvatarColor('ab' * 32), peerAvatarColor('ab' * 32));
|
expect(peerAvatarColor('ab' * 32), peerAvatarColor('ab' * 32));
|
||||||
|
|
@ -34,5 +46,14 @@ void main() {
|
||||||
expect(find.byIcon(Icons.person_outline), findsOneWidget);
|
expect(find.byIcon(Icons.person_outline), findsOneWidget);
|
||||||
expect(find.byType(Text), findsNothing);
|
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
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue