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
425
lib/presentation/screens/settings_screen.dart
Normal file
425
lib/presentation/screens/settings_screen.dart
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
import "dart:convert";
|
||||
import "dart:io";
|
||||
|
||||
import "package:auto_route/auto_route.dart";
|
||||
import "package:file_picker/file_picker.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter/services.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:share_plus/share_plus.dart";
|
||||
|
||||
import "package:smol_mail/data/providers/providers.dart";
|
||||
import "package:smol_mail/presentation/routes/app_router.gr.dart";
|
||||
import "package:smol_mail/presentation/theme/app_colors.dart";
|
||||
import "package:smol_mail/shared/utils/snackbar.dart";
|
||||
import "package:smol_mail/smol/crypto.dart";
|
||||
import "package:smol_mail/smol/errors.dart";
|
||||
import "package:smol_mail/smol/proto.dart";
|
||||
|
||||
/// Identity card, server pins, rotation and the wipe. The seed is the only
|
||||
/// secret; revealing it is always an explicit action.
|
||||
@RoutePage()
|
||||
class SettingsScreen extends ConsumerStatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
bool seedShown = false;
|
||||
final hostController = TextEditingController();
|
||||
final keyController = TextEditingController();
|
||||
final addressController = TextEditingController();
|
||||
final tokenController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
hostController.dispose();
|
||||
keyController.dispose();
|
||||
addressController.dispose();
|
||||
tokenController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _showError(Object error) {
|
||||
if (!mounted) return;
|
||||
showErrorSnackBar(context,
|
||||
error is SmolError ? error.message : "Something went wrong!");
|
||||
}
|
||||
|
||||
Future<void> _register() async {
|
||||
final client = ref.read(clientProvider);
|
||||
try {
|
||||
await client.registerAccount(addressController.text,
|
||||
token: tokenController.text.trim());
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("registered")),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
_showError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Mail, once fetched, exists only on this device — the server deletes it as
|
||||
// it is picked up (SPEC.md §5). This backs up inbox, sent mail, contacts and
|
||||
// pinned servers into one shareable file; never the seed.
|
||||
Future<void> _export() async {
|
||||
final data = ref.read(storeProvider).exportData();
|
||||
final stamp = DateTime.now().toIso8601String().substring(0, 10);
|
||||
final file = File("${Directory.systemTemp.path}/smolmail-export-$stamp.json");
|
||||
await file.writeAsString(jsonEncode(data));
|
||||
await SharePlus.instance.share(ShareParams(
|
||||
files: [XFile(file.path)], text: "kirakira backup $stamp"));
|
||||
if (mounted) {
|
||||
final messages = (data["inbox"] as List).length + (data["sent"] as List).length;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("exported $messages messages, "
|
||||
"${(data["contacts"] as Map).length} contacts, "
|
||||
"${(data["servers"] as Map).length} server keys")),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _import() async {
|
||||
final files = await FilePicker.pickFiles(
|
||||
type: FileType.custom, allowedExtensions: ["json"]);
|
||||
final path = files.isEmpty ? null : files.single.path;
|
||||
if (path == null) return;
|
||||
try {
|
||||
final data = jsonDecode(File(path).readAsStringSync()) as Map;
|
||||
final summary = await ref.read(storeProvider).importData(data);
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("imported: $summary")),
|
||||
);
|
||||
}
|
||||
} on Exception catch (err) {
|
||||
_showError(err);
|
||||
} on TypeError catch (err) {
|
||||
_showError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Bind an address this seed was already registered under, without REGISTER.
|
||||
Future<void> _recall() async {
|
||||
final client = ref.read(clientProvider);
|
||||
try {
|
||||
final addr = await client.recallAccount(addressController.text);
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("recalled ${addr.short}")),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
_showError(err);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _rotate() async {
|
||||
final client = ref.read(clientProvider);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
title: const Text("Rotate the identity key?"),
|
||||
content: const Text(
|
||||
"Contacts accept the change from the signed chain. The old key is kept, "
|
||||
"since mail sealed to it stays readable with nothing else."),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text("Cancel")),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text("Rotate")),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
try {
|
||||
final fresh = await client.rotateIdentity();
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("rotated; new key ${b32encode(fresh.publicKey)}")),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
_showError(err);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _wipe() async {
|
||||
final client = ref.read(clientProvider);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
title: const Text("Wipe everything?"),
|
||||
content: const Text(
|
||||
"Deletes the identity, every pinned key and every stored message from "
|
||||
"this device. Mail left on servers stays there."),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text("Cancel")),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text("Wipe")),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
await client.wipe();
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
if (mounted) {
|
||||
AutoRouter.of(context).replace(OnboardingRoute());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final store = ref.watch(storeProvider);
|
||||
final me = ref.watch(identityProvider);
|
||||
final account = ref.watch(accountProvider);
|
||||
if (me == null) return Scaffold(appBar: AppBar(title: const Text("Settings")));
|
||||
|
||||
final seedHex = hex(me.seed);
|
||||
final share = account?.uri(me.publicKey);
|
||||
final pins = store.allPins();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("Settings")),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
_card(
|
||||
context,
|
||||
children: [
|
||||
Text("fingerprint", style: Theme.of(context).textTheme.labelSmall),
|
||||
Text(fingerprint(me.publicKey)),
|
||||
const SizedBox(height: 15),
|
||||
Text("address", style: Theme.of(context).textTheme.labelSmall),
|
||||
Text(account?.short ?? "(not registered)"),
|
||||
const SizedBox(height: 15),
|
||||
Text("share", style: Theme.of(context).textTheme.labelSmall),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
share ?? "(register to get a shareable address)",
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (share != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
onPressed: () =>
|
||||
Clipboard.setData(ClipboardData(text: share)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_card(
|
||||
context,
|
||||
borderColor: Theme.of(context).colorScheme.error,
|
||||
children: [
|
||||
Text("seed", style: Theme.of(context).textTheme.labelSmall),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SelectableText(
|
||||
seedShown ? seedHex : "•" * 64,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
setState(() => seedShown = !seedShown),
|
||||
child: Text(seedShown ? "hide" : "reveal"),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
onPressed: () =>
|
||||
Clipboard.setData(ClipboardData(text: seedHex)),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
"back this seed up; it is the only secret",
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelSmall!
|
||||
.copyWith(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
"retired keys: ${store.identities().length - 1} (kept to read old mail)",
|
||||
style: Theme.of(context).textTheme.labelSmall),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 20),
|
||||
Text("pinned servers", style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 10),
|
||||
for (final (host, key) in pins)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(host),
|
||||
subtitle: Text(
|
||||
b32encode(key),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline, size: 18),
|
||||
onPressed: () {
|
||||
store.unpinServer(host);
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
),
|
||||
if (pins.isEmpty)
|
||||
Text("no pinned servers", style: Theme.of(context).textTheme.labelSmall),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: hostController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Host",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: keyController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Server public key (base32)",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
try {
|
||||
ref
|
||||
.read(clientProvider)
|
||||
.pinServer(hostController.text, keyController.text);
|
||||
keyController.clear();
|
||||
setState(() {});
|
||||
} on SmolError catch (err) {
|
||||
_showError(err);
|
||||
} catch (err) {
|
||||
_showError(err);
|
||||
}
|
||||
},
|
||||
child: const Text("Pin server"),
|
||||
),
|
||||
if (account == null) ...[
|
||||
const SizedBox(height: 30),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 20),
|
||||
Text("register an address",
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: addressController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Address",
|
||||
hintText: "you@example.org[:1961]",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: tokenController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Invite token (optional)",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextButton(
|
||||
onPressed: _register,
|
||||
child: const Text("Register"),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _recall,
|
||||
child: const Text("Already registered? Recall"),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 30),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 20),
|
||||
Text("backup", style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
"Mail, once fetched, exists only on this device. Export backs up the "
|
||||
"inbox, sent mail, contacts and server pins — never the seed.",
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: _export,
|
||||
icon: const Icon(Icons.share, size: 18),
|
||||
label: const Text("Export"),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _import,
|
||||
icon: const Icon(Icons.file_upload, size: 18),
|
||||
label: const Text("Import"),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 10),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text("Rotate identity key"),
|
||||
trailing: const Icon(Icons.rotate_right),
|
||||
onTap: _rotate,
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text("Wipe identity and messages",
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error)),
|
||||
trailing: Icon(Icons.delete_forever,
|
||||
color: Theme.of(context).colorScheme.error),
|
||||
onTap: _wipe,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _card(BuildContext context,
|
||||
{required List<Widget> children, Color? borderColor}) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: borderColor == null ? null : Border.all(color: borderColor),
|
||||
),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: children),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue