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
d28454b010
commit
461bc3cb36
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": {
|
||||
"title": "Guardar la so semiente",
|
||||
"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": {
|
||||
"title": "Saving its seed",
|
||||
"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": {
|
||||
"title": "Conservar su semilla",
|
||||
"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": {
|
||||
"title": "Guardar a sua semente",
|
||||
"subtitle": "O que é preciso para manter a variedade fiel",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ class TranslationsAst extends Translations with BaseTranslations<AppLocale, Tran
|
|||
TranslationsAst $copyWith({TranslationMetadata<AppLocale, Translations>? 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<AppLocale, Tran
|
|||
@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
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
|
|||
Translations $copyWith({TranslationMetadata<AppLocale, Translations>? 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<AppLocale, Translations> {
|
|||
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',
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
|
|||
TranslationsEs $copyWith({TranslationMetadata<AppLocale, Translations>? 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<AppLocale, Trans
|
|||
@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
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ class TranslationsPt extends Translations with BaseTranslations<AppLocale, Trans
|
|||
TranslationsPt $copyWith({TranslationMetadata<AppLocale, Translations>? 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<AppLocale, Trans
|
|||
@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
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<String?> name(String pubkeyHex) async {
|
||||
final value = await _store.read('$_prefix$pubkeyHex');
|
||||
|
|
@ -22,6 +26,16 @@ class ProfileCache {
|
|||
|
||||
Future<void> setName(String pubkeyHex, String name) =>
|
||||
_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.
|
||||
|
|
|
|||
|
|
@ -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<String> name() async => (await _store.read(_nameKey)) ?? '';
|
||||
Future<String> about() async => (await _store.read(_aboutKey)) ?? '';
|
||||
|
|
@ -24,13 +25,20 @@ class ProfileStore {
|
|||
/// Your Ğ1 (Duniter) address, if you chose to share one.
|
||||
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({
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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';
|
||||
|
||||
/// 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:<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 {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue