kirakira/lib/presentation/screens/contact_detail_screen.dart

214 lines
8.2 KiB
Dart
Raw Normal View History

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<ContactDetailScreen> createState() =>
_ContactDetailScreenState();
}
class _ContactDetailScreenState extends ConsumerState<ContactDetailScreen> {
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<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);
}
}
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) {
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),
],
),
);
}