import 'package:flutter/material.dart'; import '../services/offer_thumbnail.dart' show decodeDataUri; import '../services/profile_cache.dart'; 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 legacy `tane:seed:` illustration) it's /// shown; otherwise it falls back to a standard disc coloured deterministically /// from their [pubkey], carrying the first letter of their [name] (a person icon /// when unknown). Same pubkey → same colour on every device, so a person stays /// visually recognizable without sharing anything. 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, backgroundColor: peerAvatarColor(pubkey), child: letter == null ? Icon(Icons.person_outline, size: radius, color: Colors.white) : Text( letter, style: TextStyle( color: Colors.white, fontSize: radius, fontWeight: FontWeight.w600, ), ), ); } static String? _initial(String? name) { if (name == null) return null; final trimmed = name.trim(); if (trimmed.isEmpty) return null; // characters.first handles emoji/combining marks safely. return trimmed.characters.first.toUpperCase(); } } /// A [PeerAvatar] that looks the person's published avatar up from the /// [ProfileCache] (their kind:0 `picture`), falling back to the coloured-initial /// disc while it loads or when none is cached / no cache is available. Use at /// list/row sites (one avatar each); for many avatars of the same few people /// (chat bubbles) resolve the picture once into state instead. class CachedAvatar extends StatelessWidget { const CachedAvatar({ required this.pubkey, this.name, this.cache, this.radius = 14, super.key, }); final String pubkey; final String? name; final ProfileCache? cache; final double radius; @override Widget build(BuildContext context) { final c = cache; if (c == null) { return PeerAvatar(pubkey: pubkey, name: name, radius: radius); } return FutureBuilder( future: c.picture(pubkey), builder: (context, snap) => PeerAvatar( pubkey: pubkey, name: name, picture: snap.data, radius: radius, ), ); } } /// A deterministic, readable avatar colour derived from [pubkey]. Hashes the /// key to a hue, then fixes saturation/lightness so white text stays legible on /// top. Pure and stable — exposed for testing. Color peerAvatarColor(String pubkey) { // FNV-1a over the code units — cheap, stable, well-spread across hues. var hash = 0x811c9dc5; for (final unit in pubkey.codeUnits) { hash = (hash ^ unit) * 0x01000193; hash &= 0xffffffff; } final hue = (hash % 360).toDouble(); return HSLColor.fromAHSL(1, hue, 0.45, 0.42).toColor(); }