2026-09-26 22:45:38 +03:00
|
|
|
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";
|
2026-09-28 23:49:39 +03:00
|
|
|
import "package:smol_mail/smol/address.dart";
|
2026-09-30 17:14:47 +03:00
|
|
|
import "package:smol_mail/presentation/widgets/qr_code_sheet.dart";
|
2026-09-26 22:45:38 +03:00
|
|
|
import "package:smol_mail/smol/errors.dart";
|
2026-09-28 23:49:39 +03:00
|
|
|
import "package:smol_mail/smol/ui.dart";
|
2026-09-26 22:45:38 +03:00
|
|
|
|
|
|
|
|
/// 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<ContactDetailScreen> createState() =>
|
|
|
|
|
_ContactDetailScreenState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _ContactDetailScreenState extends ConsumerState<ContactDetailScreen> {
|
|
|
|
|
bool resolving = false;
|
2026-09-27 11:07:11 +03:00
|
|
|
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<void> _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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-26 22:45:38 +03:00
|
|
|
|
|
|
|
|
Future<void> _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) {
|
2026-09-27 11:07:11 +03:00
|
|
|
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;
|
2026-09-30 17:14:47 +03:00
|
|
|
// Computed once per build; the branch below only renders it when a
|
|
|
|
|
// contact is bound to the address, which is when the parse succeeds.
|
|
|
|
|
final uri =
|
|
|
|
|
contact == null ? null : parseAddress(widget.address).uri(contact.key);
|
2026-09-26 22:45:38 +03:00
|
|
|
|
|
|
|
|
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),
|
2026-09-27 11:07:11 +03:00
|
|
|
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),
|
2026-09-26 22:45:38 +03:00
|
|
|
_field(context, "fingerprint", fingerprint(contact.key)),
|
2026-09-28 23:49:39 +03:00
|
|
|
_field(context, "public key", contact.key),
|
2026-09-26 22:45:38 +03:00
|
|
|
Row(
|
|
|
|
|
children: [
|
|
|
|
|
Expanded(
|
2026-09-30 17:14:47 +03:00
|
|
|
child: _field(context, "address", uri!),
|
2026-09-26 22:45:38 +03:00
|
|
|
),
|
|
|
|
|
IconButton(
|
|
|
|
|
icon: const Icon(Icons.copy, size: 18),
|
2026-09-30 17:14:47 +03:00
|
|
|
onPressed: () => copyText(context, uri),
|
|
|
|
|
),
|
|
|
|
|
IconButton(
|
|
|
|
|
tooltip: "Show QR code",
|
|
|
|
|
icon: const Icon(Icons.qr_code, size: 18),
|
|
|
|
|
onPressed: () => showQrCodeSheet(context, uri),
|
2026-09-26 22:45:38 +03:00
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
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)),
|
2026-09-28 23:49:39 +03:00
|
|
|
Text(entry.key,
|
2026-09-26 22:45:38 +03:00
|
|
|
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),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|