import "package:auto_route/auto_route.dart"; import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:smol_mail/data/providers/providers.dart"; import "package:smol_mail/shared/utils/format.dart"; import "package:smol_mail/shared/utils/snackbar.dart"; import "package:smol_mail/smol/errors.dart"; import "package:smol_mail/smol/proto.dart"; /// The full key, the address to hand out, and any key this one replaced — /// the row can only afford a fingerprint, and §8 turns on the user being able /// to inspect exactly what changed. @RoutePage() class ContactDetailScreen extends ConsumerStatefulWidget { final String address; const ContactDetailScreen({super.key, required this.address}); @override ConsumerState createState() => _ContactDetailScreenState(); } class _ContactDetailScreenState extends ConsumerState { bool resolving = false; bool tokenBusy = false; // §5.8: admitting or withdrawing a contact's accept token pushes the // change to the server right away, since it only takes effect once the // server holds the changed set. Future _toggleAccepted(bool currentlyAccepted) async { final client = ref.read(clientProvider); setState(() => tokenBusy = true); try { if (currentlyAccepted) { await client.blockContact(widget.address); } else { await client.acceptContact(widget.address); } ref.read(revisionProvider.notifier).bump(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(currentlyAccepted ? "${widget.address} blocked; their mail now lands in requests" : "${widget.address} accepted; their mail now lands in your main tier")), ); } } on SmolError catch (err) { if (mounted) showErrorSnackBar(context, err.message); } finally { if (mounted) setState(() => tokenBusy = false); } } Future _reResolve() async { final client = ref.read(clientProvider); setState(() => resolving = true); try { final outcome = await client.refreshContact(widget.address); ref.read(revisionProvider.notifier).bump(); if (mounted) { // A rotation or an unexplained key change is §8 material; it stays // until dismissed and stands out like any other warning. if (outcome.warn) { showErrorSnackBar(context, outcome.message, duration: const Duration(days: 1)); } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(outcome.message)), ); } } } on SmolError catch (err) { if (mounted) { showErrorSnackBar(context, err.message); } } finally { if (mounted) setState(() => resolving = false); } } @override Widget build(BuildContext context) { ref.watch(revisionProvider); final store = ref.read(storeProvider); final contact = store.contact(widget.address); final accepted = store.accepted(widget.address); final isAccepted = accepted != null && accepted.active; return Scaffold( appBar: AppBar(title: Text(widget.address)), body: contact == null ? Center( child: Text("no such contact", style: Theme.of(context).textTheme.labelSmall)) : ListView( padding: const EdgeInsets.all(20), children: [ _chip(context, contact.verified ? "verified key" : "key pinned on first use"), const SizedBox(height: 20), Text( "Accept token (SPEC.md §5.8)", style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 5), Text( isAccepted ? "This contact's mail lands in your main tier." : "This contact's mail lands in requests until accepted.", style: Theme.of(context).textTheme.labelSmall, ), const SizedBox(height: 10), tokenBusy ? const Center(child: CircularProgressIndicator()) : TextButton.icon( onPressed: () => _toggleAccepted(isAccepted), icon: Icon(isAccepted ? Icons.block : Icons.check_circle_outline, size: 18), label: Text(isAccepted ? "Block" : "Accept"), ), const SizedBox(height: 20), const Divider(height: 1), const SizedBox(height: 20), _field(context, "fingerprint", fingerprint(contact.key)), _field(context, "public key", b32encode(contact.key)), Row( children: [ Expanded( child: _field( context, "address", parseAddress(widget.address) .uri(contact.key)), ), IconButton( icon: const Icon(Icons.copy, size: 18), onPressed: () => copyText( context, parseAddress(widget.address).uri(contact.key)), ), ], ), if (contact.history.isNotEmpty) ...[ const SizedBox(height: 30), Text("previous keys (${contact.history.length})", style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 5), Text( "Replaced by a signed rotation chain (SPEC.md §7). Mail signed with these " "was sent before the change; rotation is not revocation, so a stolen key " "can still rotate onward.", style: Theme.of(context).textTheme.labelSmall, ), const SizedBox(height: 10), for (final entry in contact.history) Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(fingerprint(entry.key)), Text(b32encode(entry.key), style: Theme.of(context).textTheme.labelSmall), Text( "replaced ${formatTime(entry.until ~/ 1000)}", style: Theme.of(context).textTheme.labelSmall, ), ], ), ), ], const SizedBox(height: 30), resolving ? const Center(child: CircularProgressIndicator()) : TextButton.icon( onPressed: _reResolve, icon: const Icon(Icons.refresh, size: 18), label: const Text("Re-resolve"), ), ], ), ); } Widget _chip(BuildContext context, String text) => Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( border: Border.all(color: Theme.of(context).primaryColor), borderRadius: BorderRadius.circular(10), ), child: Text( text, style: Theme.of(context) .textTheme .labelSmall! .copyWith(color: Theme.of(context).primaryColor), ), ); Widget _field(BuildContext context, String label, String value) => Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: Theme.of(context).textTheme.labelSmall), SelectableText(value, style: Theme.of(context).textTheme.bodySmall), ], ), ); }