159 lines
6 KiB
Dart
159 lines
6 KiB
Dart
|
|
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;
|
||
|
|
|
||
|
|
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) {
|
||
|
|
final contact = ref.watch(storeProvider).contact(widget.address);
|
||
|
|
|
||
|
|
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),
|
||
|
|
_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),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|