Follow-up to the avatar feature: peers' published photos/illustrations now actually render where people appear. - CachedAvatar: a PeerAvatar that resolves the person's picture from the ProfileCache (FutureBuilder), falling back to the coloured-initial disc. For list/row sites — one avatar each. - Chat bubbles: the peer's and my own avatar are resolved once into state (_peerPicture/_selfPicture — from the cache / ProfileStore, freshened from the peer's published profile) and threaded to each bubble, so many bubbles of two people don't each hit the cache. - Chat list + your-people rows: swapped the static person-icon disc for CachedAvatar, so photos/illustrations show there too. - Tests: CachedAvatar render modes (cached illustration / initial / no cache). analyze clean; chat + your-people suites green. Market offers show no author avatar today (would be a new element, not a swap) — left out of scope.
251 lines
7.3 KiB
Dart
251 lines
7.3 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:commons_core/commons_core.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import '../i18n/strings.g.dart';
|
|
import '../services/profile_cache.dart';
|
|
import 'peer_avatar.dart';
|
|
import '../services/social_connection.dart';
|
|
import '../services/social_service.dart';
|
|
import 'theme.dart';
|
|
|
|
/// "Your people": who you vouch for and who vouches for you, in human
|
|
/// language. Ego-centric by design — no roots, no parameters, no graph talk.
|
|
/// Reached from the profile; rows open the 1:1 chat.
|
|
class YourPeopleScreen extends StatefulWidget {
|
|
const YourPeopleScreen({
|
|
required this.social,
|
|
required this.connection,
|
|
this.profileCache,
|
|
super.key,
|
|
});
|
|
|
|
final SocialService social;
|
|
|
|
/// The shared relay connection (one per identity), carrying trust.
|
|
final SocialConnection connection;
|
|
|
|
/// Optional cache of peer display names; null in tests.
|
|
final ProfileCache? profileCache;
|
|
|
|
@override
|
|
State<YourPeopleScreen> createState() => _YourPeopleScreenState();
|
|
}
|
|
|
|
class _YourPeopleScreenState extends State<YourPeopleScreen> {
|
|
TrustTransport? _transport;
|
|
bool _loading = true;
|
|
bool _busy = false;
|
|
List<String> _youVouchFor = const [];
|
|
List<String> _vouchForYou = const [];
|
|
final Map<String, String> _names = {};
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_init();
|
|
}
|
|
|
|
Future<void> _init() async {
|
|
final session = await widget.connection.session();
|
|
if (!mounted) return; // shared session is owned by the connection, not us
|
|
_transport = session?.trust;
|
|
await _load();
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
final transport = _transport;
|
|
if (transport == null) {
|
|
if (mounted) setState(() => _loading = false);
|
|
return;
|
|
}
|
|
final self = widget.social.publicKeyHex;
|
|
final now = DateTime.now();
|
|
final certs = (await transport.allCertifications())
|
|
.where((c) => c.isValidAt(now))
|
|
.toList();
|
|
final given = [
|
|
for (final c in certs)
|
|
if (c.issuer == self) c.subject,
|
|
]..sort();
|
|
final received = [
|
|
for (final c in certs)
|
|
if (c.subject == self) c.issuer,
|
|
]..sort();
|
|
final cache = widget.profileCache;
|
|
if (cache != null) {
|
|
for (final peer in {...given, ...received}) {
|
|
final name = await cache.name(peer);
|
|
if (name != null) _names[peer] = name;
|
|
}
|
|
}
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_youVouchFor = given;
|
|
_vouchForYou = received;
|
|
_loading = false;
|
|
});
|
|
}
|
|
|
|
Future<void> _revoke(String peer) async {
|
|
final t = context.t;
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(t.yourPeople.revokeConfirm),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: Text(t.common.cancel),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
child: Text(t.yourPeople.revoke),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
final transport = _transport;
|
|
if (confirmed != true || transport == null || !mounted) return;
|
|
setState(() => _busy = true);
|
|
await transport.revoke(subjectPubkey: peer);
|
|
await _load();
|
|
if (mounted) setState(() => _busy = false);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.t;
|
|
return Scaffold(
|
|
appBar: AppBar(title: Text(t.yourPeople.title)),
|
|
body: _loading
|
|
? const Center(child: CircularProgressIndicator())
|
|
: _transport == null
|
|
? Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Text(
|
|
t.yourPeople.offline,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(color: seedMuted, fontSize: 15),
|
|
),
|
|
),
|
|
)
|
|
: ListView(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsetsDirectional.fromSTEB(
|
|
16, 8, 16, 8),
|
|
child: Text(
|
|
t.yourPeople.help,
|
|
style:
|
|
const TextStyle(color: seedMuted, fontSize: 14),
|
|
),
|
|
),
|
|
_SectionHeader(label: t.yourPeople.youVouchFor),
|
|
if (_youVouchFor.isEmpty)
|
|
_EmptyNote(text: t.yourPeople.youVouchForEmpty)
|
|
else
|
|
for (final peer in _youVouchFor)
|
|
_PersonTile(
|
|
key: Key('yourPeople.given.$peer'),
|
|
pubkey: peer,
|
|
name: _names[peer] ?? shortPubkey(peer),
|
|
cache: widget.profileCache,
|
|
onOpen: () => context.push('/chat/$peer'),
|
|
trailing: TextButton(
|
|
onPressed: _busy ? null : () => _revoke(peer),
|
|
child: Text(t.yourPeople.revoke),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_SectionHeader(label: t.yourPeople.vouchesForYou),
|
|
if (_vouchForYou.isEmpty)
|
|
_EmptyNote(text: t.yourPeople.vouchesForYouEmpty)
|
|
else
|
|
for (final peer in _vouchForYou)
|
|
_PersonTile(
|
|
key: Key('yourPeople.received.$peer'),
|
|
pubkey: peer,
|
|
name: _names[peer] ?? shortPubkey(peer),
|
|
cache: widget.profileCache,
|
|
onOpen: () => context.push('/chat/$peer'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SectionHeader extends StatelessWidget {
|
|
const _SectionHeader({required this.label});
|
|
|
|
final String label;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsetsDirectional.fromSTEB(16, 12, 16, 4),
|
|
child: Text(
|
|
label,
|
|
style: const TextStyle(
|
|
color: seedGreen,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _EmptyNote extends StatelessWidget {
|
|
const _EmptyNote({required this.text});
|
|
|
|
final String text;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsetsDirectional.fromSTEB(16, 4, 16, 4),
|
|
child: Text(
|
|
text,
|
|
style: const TextStyle(color: seedMuted, fontSize: 14),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PersonTile extends StatelessWidget {
|
|
const _PersonTile({
|
|
required this.name,
|
|
required this.onOpen,
|
|
required this.pubkey,
|
|
this.cache,
|
|
this.trailing,
|
|
super.key,
|
|
});
|
|
|
|
final String pubkey;
|
|
final String name;
|
|
final ProfileCache? cache;
|
|
final VoidCallback onOpen;
|
|
final Widget? trailing;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListTile(
|
|
leading: CachedAvatar(
|
|
pubkey: pubkey,
|
|
name: name,
|
|
cache: cache,
|
|
radius: 20,
|
|
),
|
|
title: Text(name),
|
|
trailing: trailing,
|
|
onTap: onOpen,
|
|
);
|
|
}
|
|
}
|