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
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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue