feat: replace mail protocol with smolmail and rebuild the UI
This commit is contained in:
parent
cb24b68069
commit
e94e4158cc
93 changed files with 6387 additions and 3479 deletions
158
lib/presentation/screens/contact_detail_screen.dart
Normal file
158
lib/presentation/screens/contact_detail_screen.dart
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue