425 lines
15 KiB
Dart
425 lines
15 KiB
Dart
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),
|
|
);
|
|
}
|
|
}
|