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
|
|
@ -1,34 +0,0 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:auto_route/auto_route.dart";
|
||||
|
||||
import "package:flash_mail/presentation/widgets/auth/account_buttons.dart";
|
||||
|
||||
@RoutePage()
|
||||
class AccountScreen extends StatelessWidget {
|
||||
const AccountScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: <Widget>[
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
const Image(
|
||||
image: AssetImage("assets/images/logo-small.png"),
|
||||
),
|
||||
Text(
|
||||
"FlashMail",
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
)
|
||||
],
|
||||
),
|
||||
const AccountButtons()
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
127
lib/presentation/screens/compose_screen.dart
Normal file
127
lib/presentation/screens/compose_screen.dart
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
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/presentation/widgets/primary_button.dart";
|
||||
import "package:smol_mail/presentation/widgets/small_loading_spinner.dart";
|
||||
import "package:smol_mail/presentation/theme/app_colors.dart";
|
||||
import "package:smol_mail/shared/utils/snackbar.dart";
|
||||
import "package:smol_mail/smol/errors.dart";
|
||||
|
||||
/// One tap seals one envelope; the button disables while the send is in
|
||||
/// flight so a second tap cannot seal a second message.
|
||||
@RoutePage()
|
||||
class ComposeScreen extends ConsumerStatefulWidget {
|
||||
final String? to;
|
||||
final String? subject;
|
||||
|
||||
const ComposeScreen({
|
||||
super.key,
|
||||
this.to,
|
||||
this.subject,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ComposeScreen> createState() => _ComposeScreenState();
|
||||
}
|
||||
|
||||
class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||
late final toController = TextEditingController(text: widget.to ?? "");
|
||||
late final subjectController =
|
||||
TextEditingController(text: widget.subject ?? "");
|
||||
final bodyController = TextEditingController();
|
||||
bool anonymous = false;
|
||||
bool busy = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
toController.dispose();
|
||||
subjectController.dispose();
|
||||
bodyController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _send() async {
|
||||
final client = ref.read(clientProvider);
|
||||
setState(() => busy = true);
|
||||
try {
|
||||
final to = await client.send(
|
||||
toController.text,
|
||||
subjectController.text,
|
||||
bodyController.text,
|
||||
anonymous: anonymous,
|
||||
);
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text("sent to $to")));
|
||||
AutoRouter.of(context).pop();
|
||||
}
|
||||
} on SmolError catch (err) {
|
||||
if (mounted) {
|
||||
showErrorSnackBar(context, err.message);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("Write")),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: toController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "To",
|
||||
hintText: "user@host[:1961] or smol://user@host/key",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
TextField(
|
||||
controller: subjectController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Subject",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
TextField(
|
||||
controller: bodyController,
|
||||
maxLines: 12,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Body",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: anonymous,
|
||||
onChanged: (value) => setState(() => anonymous = value),
|
||||
title: Text(
|
||||
"Anonymous (omit the signed Reply-To)",
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Center(
|
||||
child: PrimaryButton(
|
||||
onPressed: busy ? () {} : _send,
|
||||
child: busy ? const SmallLoadingSpinner() : const Text("Send"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
130
lib/presentation/screens/contacts_screen.dart
Normal file
130
lib/presentation/screens/contacts_screen.dart
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
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/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/errors.dart";
|
||||
import "package:smol_mail/smol/proto.dart";
|
||||
|
||||
/// Contacts: the addresses bound to keys, with the trust badge and the number
|
||||
/// of keys each has replaced — the §8 surface for noticing rotations.
|
||||
@RoutePage()
|
||||
class ContactsScreen extends ConsumerWidget {
|
||||
const ContactsScreen({super.key});
|
||||
|
||||
Future<void> _import(BuildContext context, WidgetRef ref) async {
|
||||
final client = ref.read(clientProvider);
|
||||
final controller = TextEditingController();
|
||||
final text = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
title: const Text("Import contact"),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: "smol://user@host/key",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).colorScheme.surface,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(controller.text),
|
||||
child: const Text("Import"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (text == null || text.trim().isEmpty) return;
|
||||
try {
|
||||
client.importContact(text);
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("contact imported (verified key)")),
|
||||
);
|
||||
}
|
||||
} on SmolError catch (err) {
|
||||
if (context.mounted) {
|
||||
showErrorSnackBar(context, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final contacts = ref.watch(contactsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("Contacts"),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: "Import smol:// address",
|
||||
onPressed: () => _import(context, ref),
|
||||
icon: const Icon(Icons.person_add),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: contacts.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
"no contacts yet — import a smol:// address, or write to someone",
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: contacts.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final (address, contact) = contacts[i];
|
||||
return ListTile(
|
||||
splashColor: Theme.of(context).extension<AppColors>()!.highlight,
|
||||
onTap: () => AutoRouter.of(context)
|
||||
.push(ContactDetailRoute(address: address)),
|
||||
title: Text(address),
|
||||
subtitle: Text(
|
||||
fingerprint(contact.key),
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
trailing: Wrap(
|
||||
spacing: 6,
|
||||
children: [
|
||||
_chip(context, contact.verified ? "verified" : "tofu",
|
||||
ok: contact.verified),
|
||||
if (contact.history.isNotEmpty)
|
||||
_chip(context, "${contact.history.length} previous",
|
||||
ok: false),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chip(BuildContext context, String text, {required bool ok}) =>
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: ok
|
||||
? Theme.of(context).primaryColor
|
||||
: Theme.of(context).textTheme.labelSmall!.color!,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.labelSmall!.copyWith(
|
||||
color: ok
|
||||
? Theme.of(context).primaryColor
|
||||
: Theme.of(context).textTheme.labelSmall!.color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -1,62 +1,82 @@
|
|||
import "dart:async";
|
||||
|
||||
import "package:auto_route/auto_route.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
import "package:flash_mail/data/providers/providers.dart";
|
||||
import "package:flash_mail/presentation/widgets/drawer/app_drawer.dart";
|
||||
import "package:flash_mail/presentation/widgets/image_banner.dart";
|
||||
import "package:flash_mail/presentation/widgets/message/message_list.dart";
|
||||
import "package:flash_mail/presentation/widgets/shimmer/message_shimmer.dart";
|
||||
import "package:smol_mail/data/providers/providers.dart";
|
||||
import "package:smol_mail/presentation/routes/app_router.gr.dart";
|
||||
import "package:smol_mail/presentation/widgets/drawer/app_drawer.dart";
|
||||
import "package:smol_mail/presentation/widgets/image_banner.dart";
|
||||
import "package:smol_mail/presentation/widgets/message/message_list.dart";
|
||||
import "package:smol_mail/shared/utils/snackbar.dart";
|
||||
import "package:smol_mail/smol/errors.dart";
|
||||
|
||||
/// No server push in v1 (SPEC.md §13): new mail arrives when the user taps
|
||||
/// fetch, which pulls everything and acknowledges what verifies.
|
||||
@RoutePage()
|
||||
class InboxScreen extends ConsumerStatefulWidget {
|
||||
class InboxScreen extends ConsumerWidget {
|
||||
const InboxScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<InboxScreen> createState() => _InboxScreenState();
|
||||
}
|
||||
|
||||
class _InboxScreenState extends ConsumerState<InboxScreen> {
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_timer = Timer.periodic(
|
||||
const Duration(seconds: 30),
|
||||
(timer) => ref.refresh(messagesProvider),
|
||||
);
|
||||
Future<void> _fetch(BuildContext context, WidgetRef ref) async {
|
||||
final client = ref.read(clientProvider);
|
||||
try {
|
||||
final summary = await client.fetch();
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
if (context.mounted) {
|
||||
final tail = summary.rejected.isEmpty
|
||||
? ""
|
||||
: "\n${summary.rejected.length} left on the server";
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("${summary.stored} new$tail")),
|
||||
);
|
||||
}
|
||||
} on SmolError catch (err) {
|
||||
if (context.mounted) {
|
||||
showErrorSnackBar(context, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final messages = ref.watch(messagesProvider);
|
||||
final folder = ref.watch(folderProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("Inbox"),
|
||||
title: Text(folder == "inbox" ? "Inbox" : "Sent"),
|
||||
actions: [
|
||||
if (folder == "inbox")
|
||||
IconButton(
|
||||
tooltip: "Fetch",
|
||||
onPressed: () => _fetch(context, ref),
|
||||
icon: const Icon(Icons.cloud_download),
|
||||
),
|
||||
],
|
||||
),
|
||||
drawer: const AppDrawer(),
|
||||
body: messages.when(
|
||||
data: (data) => MessageList(
|
||||
messages: data,
|
||||
onRefresh: () => ref.refresh(messagesProvider.future),
|
||||
),
|
||||
error: (err, _) => const ImageBanner(
|
||||
imgSrc: "assets/images/error.png",
|
||||
text: "Something went wrong!",
|
||||
),
|
||||
loading: () => const MessageShimmer(),
|
||||
skipLoadingOnRefresh: true,
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => AutoRouter.of(context).push(ComposeRoute()),
|
||||
child: const Icon(Icons.edit),
|
||||
),
|
||||
body: messages.isEmpty
|
||||
? RefreshIndicator(
|
||||
onRefresh: () => folder == "inbox"
|
||||
? _fetch(context, ref)
|
||||
: Future.value(),
|
||||
child: ListView(
|
||||
children: const [
|
||||
ImageBanner(
|
||||
imgSrc: "assets/images/empty.png",
|
||||
text: "Nothing here yet!",
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: MessageList(
|
||||
onRefresh: () => folder == "inbox"
|
||||
? _fetch(context, ref)
|
||||
: Future.value(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:auto_route/auto_route.dart";
|
||||
|
||||
import "package:flash_mail/presentation/widgets/auth/login_form.dart";
|
||||
|
||||
@RoutePage()
|
||||
class LoginScreen extends StatelessWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(25),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
"Login",
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 25),
|
||||
LoginForm(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,69 +1,128 @@
|
|||
import "dart:typed_data";
|
||||
|
||||
import "package:auto_route/auto_route.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
import "package:flash_mail/data/providers/providers.dart";
|
||||
import "package:flash_mail/presentation/widgets/image_banner.dart";
|
||||
import "package:flash_mail/presentation/widgets/message/message_view.dart";
|
||||
import "package:smol_mail/data/providers/providers.dart";
|
||||
import "package:smol_mail/presentation/widgets/image_banner.dart";
|
||||
import "package:smol_mail/presentation/widgets/message/message_view.dart";
|
||||
import "package:smol_mail/presentation/theme/app_colors.dart";
|
||||
import "package:smol_mail/shared/utils/snackbar.dart";
|
||||
import "package:smol_mail/smol/errors.dart";
|
||||
|
||||
/// Fetched mail is acknowledged off the server during fetch, so deleting here
|
||||
/// removes only the local sealed copy.
|
||||
@RoutePage()
|
||||
class MessageDetailScreen extends ConsumerWidget {
|
||||
final String folder;
|
||||
final String id;
|
||||
final String subject;
|
||||
|
||||
const MessageDetailScreen({
|
||||
super.key,
|
||||
required this.folder,
|
||||
required this.id,
|
||||
required this.subject,
|
||||
});
|
||||
|
||||
void deleteMessage(BuildContext context, WidgetRef ref) async {
|
||||
await ref.read(clientProvider).deleteMessage(id);
|
||||
ref.invalidate(messagesProvider);
|
||||
Future<void> _delete(BuildContext context, WidgetRef ref) async {
|
||||
await ref.read(storeProvider).deleteMessage(folder, id);
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
if (context.mounted) {
|
||||
AutoRouter.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _nameSender(BuildContext context, WidgetRef ref,
|
||||
Uint8List senderKey) async {
|
||||
final client = ref.read(clientProvider);
|
||||
final controller = TextEditingController();
|
||||
final address = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: "user@host or smol://…",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).colorScheme.surface,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(controller.text),
|
||||
child: const Text("Save"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (address == null || address.trim().isEmpty) return;
|
||||
try {
|
||||
await client.nameSender(address, senderKey);
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("Address saved for this sender")),
|
||||
);
|
||||
}
|
||||
} on SmolError catch (err) {
|
||||
if (context.mounted) {
|
||||
showErrorSnackBar(context, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final message = ref.watch(messageDetailProvider(id));
|
||||
final record = ref.watch(storeProvider).getMessage(folder, id);
|
||||
if (record == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: const ImageBanner(
|
||||
imgSrc: "assets/images/error.png",
|
||||
text: "Message not found!",
|
||||
),
|
||||
);
|
||||
}
|
||||
final client = ref.read(clientProvider);
|
||||
final opened = client.describe(record);
|
||||
final isSent = folder == "sent";
|
||||
final who = isSent
|
||||
? (record.recipient ?? "")
|
||||
: (opened.sender == null
|
||||
? ""
|
||||
: ref.watch(storeProvider).addressForKey(opened.sender!) ?? "");
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: who.isEmpty
|
||||
? null
|
||||
: Text(
|
||||
who,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontFamily: "Inter",
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 18,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => deleteMessage(context, ref),
|
||||
icon: const Icon(Icons.delete, color: Colors.white),
|
||||
)
|
||||
tooltip: "Delete local copy",
|
||||
onPressed: () => _delete(context, ref),
|
||||
icon: const Icon(Icons.delete),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
subject,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge!
|
||||
.copyWith(fontSize: 20),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
message.when(
|
||||
data: (data) => MessageView(message: data),
|
||||
error: (err, _) => const ImageBanner(
|
||||
imgSrc: "assets/images/error.png",
|
||||
text: "Something went wrong!",
|
||||
),
|
||||
loading: () => const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
child: MessageView(
|
||||
record: record,
|
||||
opened: opened,
|
||||
onNameSender: opened.sender == null
|
||||
? null
|
||||
: (key) => _nameSender(context, ref, key),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
324
lib/presentation/screens/onboarding_screen.dart
Normal file
324
lib/presentation/screens/onboarding_screen.dart
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
import "package:auto_route/auto_route.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter/services.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
import "package:smol_mail/data/providers/providers.dart";
|
||||
import "package:smol_mail/presentation/routes/app_router.gr.dart";
|
||||
import "package:smol_mail/presentation/widgets/primary_button.dart";
|
||||
import "package:smol_mail/presentation/widgets/secondary_button.dart";
|
||||
import "package:smol_mail/presentation/widgets/small_loading_spinner.dart";
|
||||
import "package:smol_mail/smol/crypto.dart";
|
||||
import "package:smol_mail/smol/errors.dart";
|
||||
import "package:smol_mail/smol/proto.dart";
|
||||
import "package:smol_mail/presentation/theme/app_colors.dart";
|
||||
import "package:smol_mail/shared/utils/snackbar.dart";
|
||||
|
||||
/// First-run flow: create or restore an identity, back the seed up, pin the
|
||||
/// home server's key and register an address (SPEC.md §4: registration against
|
||||
/// an unpinned server is not permitted).
|
||||
@RoutePage()
|
||||
class OnboardingScreen extends ConsumerStatefulWidget {
|
||||
const OnboardingScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<OnboardingScreen> createState() => _OnboardingScreenState();
|
||||
}
|
||||
|
||||
enum _Step { welcome, backup, restore, register }
|
||||
|
||||
class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
||||
_Step step = _Step.welcome;
|
||||
Uint8List? createdSeed;
|
||||
bool busy = false;
|
||||
|
||||
final seedController = TextEditingController();
|
||||
final restoreAddressController = TextEditingController();
|
||||
final addressController = TextEditingController();
|
||||
final serverKeyController = TextEditingController();
|
||||
final tokenController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
seedController.dispose();
|
||||
restoreAddressController.dispose();
|
||||
addressController.dispose();
|
||||
serverKeyController.dispose();
|
||||
tokenController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _showError(Object error) {
|
||||
if (!mounted) return;
|
||||
// Replace rather than queue: a fresh error must not wait behind a stale
|
||||
// one — a queued SnackBar is indistinguishable from "nothing happened".
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
showErrorSnackBar(
|
||||
context, error is SmolError ? error.message : error.toString());
|
||||
}
|
||||
|
||||
void _createIdentity() {
|
||||
final client = ref.read(clientProvider);
|
||||
final fresh = client.createIdentity();
|
||||
setState(() {
|
||||
createdSeed = fresh.seed;
|
||||
step = _Step.backup;
|
||||
});
|
||||
}
|
||||
|
||||
void _restoreIdentity() {
|
||||
setState(() => step = _Step.restore);
|
||||
}
|
||||
|
||||
// Restoring must succeed on its own even if recall fails (server not
|
||||
// pinned yet, offline, typo) — recall can always be retried from the
|
||||
// register step or settings afterward.
|
||||
void _submitRestore() {
|
||||
final client = ref.read(clientProvider);
|
||||
try {
|
||||
client.restoreIdentity(seedController.text);
|
||||
} on Exception catch (err) {
|
||||
_showError(err);
|
||||
return;
|
||||
}
|
||||
final addressText = restoreAddressController.text.trim();
|
||||
if (addressText.isEmpty) {
|
||||
setState(() => step = _Step.register);
|
||||
return;
|
||||
}
|
||||
() async {
|
||||
try {
|
||||
await client.recallAccount(addressText);
|
||||
if (!mounted) return;
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
AutoRouter.of(context).replace(InboxRoute());
|
||||
} on Exception catch (err) {
|
||||
if (!mounted) return;
|
||||
_showError(err);
|
||||
setState(() => step = _Step.register);
|
||||
}
|
||||
}();
|
||||
}
|
||||
|
||||
Future<void> _submitRegistration() async {
|
||||
await _submit(recall: false);
|
||||
}
|
||||
|
||||
// Restoring a seed on a new device knows the identity but not the address it
|
||||
// was registered under; recall binds it without re-REGISTER.
|
||||
Future<void> _submitRecall() async {
|
||||
await _submit(recall: true);
|
||||
}
|
||||
|
||||
Future<void> _submit({required bool recall}) async {
|
||||
final client = ref.read(clientProvider);
|
||||
setState(() => busy = true);
|
||||
try {
|
||||
final address = parseAddress(addressController.text);
|
||||
// Recall needs a pin too (SPEC.md §4), and the key for it is right
|
||||
// here in the form — pin it before either path. An empty field means
|
||||
// "already pinned, e.g. by a previous attempt or a preset server".
|
||||
final serverKey = serverKeyController.text.trim();
|
||||
if (serverKey.isNotEmpty) {
|
||||
client.pinServer(address.host, serverKey);
|
||||
}
|
||||
if (recall) {
|
||||
await client.recallAccount(address.short);
|
||||
} else {
|
||||
await client.registerAccount(address.short,
|
||||
token: tokenController.text.trim());
|
||||
}
|
||||
if (mounted) {
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
AutoRouter.of(context).replace(InboxRoute());
|
||||
}
|
||||
} catch (err) {
|
||||
_showError(err);
|
||||
} finally {
|
||||
if (mounted) setState(() => busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 25),
|
||||
child: switch (step) {
|
||||
_Step.welcome => _welcome(context),
|
||||
_Step.backup => _backup(context),
|
||||
_Step.restore => _restore(context),
|
||||
_Step.register => _register(context),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _header(BuildContext context, String title) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 60),
|
||||
Row(
|
||||
children: [
|
||||
const Image(image: AssetImage("assets/images/logo-small.png")),
|
||||
const SizedBox(width: 10),
|
||||
Text("kirakira", style: Theme.of(context).textTheme.titleLarge),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _welcome(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_header(context,
|
||||
"One keypair is the whole identity: an address, a key and a message."),
|
||||
const Spacer(),
|
||||
PrimaryButton(
|
||||
onPressed: _createIdentity,
|
||||
child: const Text("Create Identity"),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
SecondaryButton(
|
||||
text: "Restore From Seed",
|
||||
onPressed: _restoreIdentity,
|
||||
),
|
||||
const SizedBox(height: 80),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _backup(BuildContext context) {
|
||||
final seed = createdSeed!;
|
||||
final seedHex = hex(seed);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_header(context, "Back up this seed; it is the only secret."),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
"fingerprint:\n${fingerprint(ed25519PublicKey(seed))}",
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
SelectableText(
|
||||
seedHex,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall!
|
||||
.copyWith(color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: seedHex));
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||
const SnackBar(content: Text("Seed copied to clipboard")),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text("Copy seed"),
|
||||
),
|
||||
const Spacer(),
|
||||
PrimaryButton(
|
||||
onPressed: () => setState(() => step = _Step.register),
|
||||
child: const Text("I have backed it up"),
|
||||
),
|
||||
const SizedBox(height: 80),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _restore(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_header(context, "Enter the 32-byte seed as 64 hex characters."),
|
||||
const SizedBox(height: 20),
|
||||
TextField(
|
||||
controller: seedController,
|
||||
decoration: InputDecoration(
|
||||
hintText: "64 hex characters",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
TextField(
|
||||
controller: restoreAddressController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Address (if already registered)",
|
||||
hintText: "alice@example.org",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
PrimaryButton(
|
||||
onPressed: _submitRestore,
|
||||
child: const Text("Restore"),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
SecondaryButton(
|
||||
text: "Back",
|
||||
onPressed: () => setState(() => step = _Step.welcome),
|
||||
),
|
||||
const Spacer(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _register(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_header(context,
|
||||
"Register an address. Pin the server's public key first — obtain it from the operator through a trusted channel."),
|
||||
const SizedBox(height: 30),
|
||||
TextField(
|
||||
controller: addressController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Address",
|
||||
hintText: "you@example.org[:1961]",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
TextField(
|
||||
controller: serverKeyController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Server public key",
|
||||
hintText: "base32, 52 characters",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
TextField(
|
||||
controller: tokenController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Invite token (optional)",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
PrimaryButton(
|
||||
onPressed: busy ? () {} : _submitRegistration,
|
||||
child: busy
|
||||
? const SmallLoadingSpinner()
|
||||
: const Text("Pin and Register"),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: busy ? () {} : _submitRecall,
|
||||
child: const Text("Already registered? Recall"),
|
||||
),
|
||||
const SizedBox(height: 80),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
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