kirakira/lib/presentation/screens/settings_screen.dart

463 lines
17 KiB
Dart
Raw Normal View History

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/address.dart";
import "package:smol_mail/smol/errors.dart";
import "package:smol_mail/smol/ui.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 store = ref.read(storeProvider);
// exportData is the sealed gsmol container itself; the file body is its
// JSON, already sealed to the master-derived key.
final bytes = Uint8List.fromList(utf8.encode(await store.exportData()));
final stamp = DateTime.now().toIso8601String().substring(0, 10);
final fileName = "kirakira-backup-$stamp.json";
try {
// share_plus has no file-sharing implementation on desktop platforms —
// it throws UnimplementedError for Linux/Windows/macOS — so those save
// straight to a chosen location instead of going through a share sheet.
if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) {
await FilePicker.saveFile(fileName: fileName, bytes: bytes);
} else {
final file = File("${Directory.systemTemp.path}/$fileName");
await file.writeAsBytes(bytes);
await SharePlus.instance.share(ShareParams(
files: [XFile(file.path)], text: "kirakira backup $stamp"));
}
if (mounted) {
// The export payload is sealed now (v2), so the counts for this
// notice come straight from the store rather than the ciphertext.
final messages = store.listMessages("inbox").length +
store.listMessages("requests").length +
store.listMessages("sent").length;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("exported $messages messages, "
"${store.allContacts().length} contacts, "
"${store.allPins().length} server keys")),
);
}
} catch (err) {
if (mounted) showErrorSnackBar(context, err.toString());
}
}
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 summary =
await ref.read(storeProvider).importData(File(path).readAsStringSync());
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 ${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 masterHex = hex(store.master()!);
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("master secret", style: Theme.of(context).textTheme.labelSmall),
Row(
children: [
Expanded(
child: SelectableText(
seedShown ? masterHex : "•" * 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: masterHex)),
),
],
),
Text(
"back this master secret up; it is the only secret",
style: Theme.of(context)
.textTheme
.labelSmall!
.copyWith(color: Theme.of(context).colorScheme.error),
),
const SizedBox(height: 10),
Text(
"rotations: ${store.rotations()} (superseded keys kept to read old mail)",
style: Theme.of(context).textTheme.labelSmall),
const SizedBox(height: 4),
Text(
"accepted correspondents: ${store.allAccepted().where((e) => e.$2.active).length}"
"${store.syncOk() ? "" : " (not yet pushed to the server — accept them again)"}",
style: Theme.of(context).textTheme.labelSmall),
],
),
const SizedBox(height: 30),
const Divider(height: 1),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text("Leave mail on server"),
subtitle: const Text(
"Fetch keeps a copy on the server instead of deleting it. "
"Delete a message here to remove it from both."),
value: store.leaveOnServer(),
onChanged: (value) {
store.setLeaveOnServer(value);
setState(() {});
},
),
const SizedBox(height: 10),
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(
key,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall,
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, size: 18),
onPressed: () {
store.unpinServer(host, defaultPort);
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, defaultPort);
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),
);
}
}