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 createState() => _SettingsScreenState(); } class _SettingsScreenState extends ConsumerState { 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 _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 _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 _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 _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 _rotate() async { final client = ref.read(clientProvider); final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( backgroundColor: Theme.of(context).extension()!.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 _wipe() async { final client = ref.read(clientProvider); final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( backgroundColor: Theme.of(context).extension()!.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()!.cardFill, ), ), const SizedBox(height: 10), TextField( controller: keyController, decoration: InputDecoration( labelText: "Server public key (base32)", filled: true, fillColor: Theme.of(context).extension()!.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()!.cardFill, ), ), const SizedBox(height: 10), TextField( controller: tokenController, decoration: InputDecoration( labelText: "Invite token (optional)", filled: true, fillColor: Theme.of(context).extension()!.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 children, Color? borderColor}) { return Container( width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Theme.of(context).extension()!.cardFill, borderRadius: BorderRadius.circular(10), border: borderColor == null ? null : Border.all(color: borderColor), ), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: children), ); } }