import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import '../di/injector.dart' show switchSocialAccount; import '../i18n/strings.g.dart'; import '../services/profile_cache.dart' show shortPubkey; 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'; /// Your own profile: the shareable identity (npub) plus a display name and short /// "about" others see instead of a raw key. Saving persists locally and, when /// online, publishes NIP-01 kind:0 metadata so peers can recognise you. class ProfileScreen extends StatefulWidget { const ProfileScreen({ required this.social, required this.connection, required this.profileStore, required this.accounts, this.yourPeopleEnabled = false, super.key, }); final SocialService social; /// The shared relay connection, for publishing your profile. final SocialConnection connection; final ProfileStore profileStore; final SocialAccountStore accounts; /// Whether to offer the "Your people" entry (needs the social layer). final bool yourPeopleEnabled; @override State createState() => _ProfileScreenState(); } class _ProfileScreenState extends State { final _name = TextEditingController(); final _about = TextEditingController(); final _g1 = TextEditingController(); String _avatar = ''; bool _loading = true; bool _saving = false; bool _switching = false; /// The identities of this root seed (account index + its npub), for the /// switcher. Always includes the active one. List<({int account, String npub})> _identities = const []; @override void initState() { super.initState(); _load(); } Future _load() async { _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++) { ids.add((account: i, npub: await widget.social.npubForAccount(i) ?? '')); } if (mounted) { setState(() { _identities = ids; _loading = false; }); } } /// Switches to (or creates) an identity and rebuilds the app on the new one. Future _switchTo(int account, {bool confirm = true}) async { if (account == widget.social.account || _switching) return; final t = context.t; if (confirm) { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(t.profile.switchTitle), content: Text(t.profile.switchBody), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: Text(t.common.cancel)), FilledButton( onPressed: () => Navigator.pop(ctx, true), child: Text(t.profile.switchAction)), ], ), ); if (ok != true) return; } if (!mounted) return; setState(() => _switching = true); await switchSocialAccount(account); if (mounted) RestartWidget.restart(context); } Future _newIdentity() async { final next = await widget.accounts.maxCreated() + 1; await _switchTo(next, confirm: false); // creating one is already explicit } Future _editAvatar() async { final result = await showAvatarPicker(context, current: _avatar); if (result == null || !mounted) return; setState(() => _avatar = result); // Persist + publish right away: an avatar change shouldn't depend on // finding the Save button further down the screen. await _save(); } Future _copyId() async { final messenger = ScaffoldMessenger.of(context); final t = context.t; await Clipboard.setData(ClipboardData(text: widget.social.npub)); if (!mounted) return; messenger.showSnackBar(SnackBar(content: Text(t.profile.copied))); } Future _save() async { 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, avatar: _avatar); // Best-effort publish over the shared connection so peers see the name. try { final session = await widget.connection.session(); await session?.profile.publish( name: _name.text.trim(), about: _about.text.trim(), picture: _avatar, g1: _g1.text.trim(), ); } catch (_) { // offline / unreachable — the local copy is saved regardless. } if (!mounted) return; setState(() => _saving = false); messenger.showSnackBar(SnackBar(content: Text(t.profile.saved))); } @override void dispose() { _name.dispose(); _about.dispose(); _g1.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final t = context.t; return Scaffold( appBar: AppBar(title: Text(t.profile.title)), body: _loading ? const Center(child: CircularProgressIndicator()) : 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( key: const Key('profile.name'), controller: _name, decoration: InputDecoration( labelText: t.profile.name, helperText: t.profile.nameHint, ), ), const SizedBox(height: 16), TextField( key: const Key('profile.about'), controller: _about, minLines: 2, maxLines: 4, decoration: InputDecoration( labelText: t.profile.about, helperText: t.profile.aboutHint, ), ), const SizedBox(height: 16), TextField( key: const Key('profile.g1'), controller: _g1, decoration: InputDecoration( labelText: t.profile.g1, helperText: t.profile.g1Hint, helperMaxLines: 2, ), ), const SizedBox(height: 24), FilledButton( key: const Key('profile.save'), onPressed: _saving ? null : _save, child: _saving ? const SizedBox( height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : Text(t.profile.save), ), const SizedBox(height: 28), const Divider(), const SizedBox(height: 12), _IdentitiesSection( identities: _identities, activeAccount: widget.social.account, busy: _switching, onSwitch: (a) => _switchTo(a), onNew: _newIdentity, ), if (widget.yourPeopleEnabled) ...[ const SizedBox(height: 12), const Divider(), ListTile( key: const Key('profile.yourPeople'), contentPadding: EdgeInsets.zero, leading: const Icon(Icons.people_alt_outlined, color: seedGreen), title: Text(t.yourPeople.title), trailing: const Icon(Icons.chevron_right, color: seedMuted), onTap: () => context.push('/your-people'), ), ], ], ), ); } } /// The identity switcher: every pseudonymous identity of this root seed, with /// the active one marked, plus "new identity". Switching keeps chats/contacts /// separate per identity; all regenerate from the single backup. class _IdentitiesSection extends StatelessWidget { const _IdentitiesSection({ required this.identities, required this.activeAccount, required this.busy, required this.onSwitch, required this.onNew, }); final List<({int account, String npub})> identities; final int activeAccount; final bool busy; final ValueChanged onSwitch; final VoidCallback onNew; @override Widget build(BuildContext context) { final t = context.t; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( t.profile.identities, style: const TextStyle( fontWeight: FontWeight.w600, color: seedOnSurface), ), const SizedBox(height: 4), Text( t.profile.identitiesHelp, style: const TextStyle(color: seedMuted, fontSize: 12), ), const SizedBox(height: 8), for (final id in identities) ListTile( contentPadding: EdgeInsets.zero, leading: Icon( id.account == activeAccount ? Icons.radio_button_checked : Icons.radio_button_unchecked, color: id.account == activeAccount ? seedGreen : seedMuted, ), title: Text(t.profile.identityLabel(n: id.account + 1)), subtitle: id.npub.isEmpty ? null : Text(shortPubkey(id.npub), style: const TextStyle( fontFamily: 'monospace', fontSize: 12)), trailing: id.account == activeAccount ? Text(t.profile.current, style: const TextStyle(color: seedGreen, fontSize: 12)) : null, onTap: busy || id.account == activeAccount ? null : () => onSwitch(id.account), ), const SizedBox(height: 4), Align( alignment: AlignmentDirectional.centerStart, child: TextButton.icon( key: const Key('profile.newIdentity'), onPressed: busy ? null : onNew, icon: const Icon(Icons.add, size: 18), label: Text(t.profile.newIdentity), ), ), ], ); } } class _IdentityCard extends StatelessWidget { const _IdentityCard({required this.npub, required this.onCopy}); final String npub; final VoidCallback onCopy; @override Widget build(BuildContext context) { final t = context.t; return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: seedPrimaryContainer.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(14), border: Border.all(color: seedOutline), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( t.profile.yourId, style: const TextStyle( fontWeight: FontWeight.w600, color: seedOnSurface, ), ), const SizedBox(height: 4), Text( t.profile.idHelp, style: const TextStyle(color: seedMuted, fontSize: 12), ), const SizedBox(height: 14), Center(child: QrView(data: npub, size: 200)), const SizedBox(height: 12), SelectableText( npub, style: const TextStyle( fontFamily: 'monospace', fontSize: 12, color: seedOnSurface, ), ), const SizedBox(height: 8), Align( alignment: AlignmentDirectional.centerEnd, child: TextButton.icon( key: const Key('profile.copyId'), onPressed: onCopy, icon: const Icon(Icons.copy, size: 16), label: Text(t.profile.copy), ), ), ], ), ); } }