feat: adopt smolmail protocol 1.1, adaptive nav shell, and server mail retention
This commit is contained in:
parent
eaaa3f2ede
commit
c693e6fcb9
27 changed files with 1190 additions and 592 deletions
|
|
@ -20,9 +20,14 @@ class AppRouter extends RootStackRouter {
|
|||
guards: [HomeGuard(ref)],
|
||||
),
|
||||
AutoRoute(
|
||||
page: InboxRoute.page,
|
||||
path: "/inbox",
|
||||
page: HomeRoute.page,
|
||||
path: "/home",
|
||||
guards: [IdentityGuard(ref)],
|
||||
children: [
|
||||
AutoRoute(page: InboxRoute.page, path: "inbox", initial: true),
|
||||
AutoRoute(page: ContactsRoute.page, path: "contacts"),
|
||||
AutoRoute(page: SettingsRoute.page, path: "settings"),
|
||||
],
|
||||
),
|
||||
AutoRoute(
|
||||
page: MessageDetailRoute.page,
|
||||
|
|
@ -34,20 +39,10 @@ class AppRouter extends RootStackRouter {
|
|||
path: "/compose",
|
||||
guards: [IdentityGuard(ref)],
|
||||
),
|
||||
AutoRoute(
|
||||
page: ContactsRoute.page,
|
||||
path: "/contacts",
|
||||
guards: [IdentityGuard(ref)],
|
||||
),
|
||||
AutoRoute(
|
||||
page: ContactDetailRoute.page,
|
||||
path: "/contact",
|
||||
guards: [IdentityGuard(ref)],
|
||||
),
|
||||
AutoRoute(
|
||||
page: SettingsRoute.page,
|
||||
path: "/settings",
|
||||
guards: [IdentityGuard(ref)],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,26 +10,27 @@
|
|||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
|
||||
import 'package:auto_route/auto_route.dart' as _i8;
|
||||
import 'package:flutter/material.dart' as _i9;
|
||||
import 'package:auto_route/auto_route.dart' as _i9;
|
||||
import 'package:flutter/material.dart' as _i10;
|
||||
import 'package:smol_mail/presentation/screens/compose_screen.dart' as _i1;
|
||||
import 'package:smol_mail/presentation/screens/contact_detail_screen.dart'
|
||||
as _i2;
|
||||
import 'package:smol_mail/presentation/screens/contacts_screen.dart' as _i3;
|
||||
import 'package:smol_mail/presentation/screens/inbox_screen.dart' as _i4;
|
||||
import 'package:smol_mail/presentation/screens/home_screen.dart' as _i4;
|
||||
import 'package:smol_mail/presentation/screens/inbox_screen.dart' as _i5;
|
||||
import 'package:smol_mail/presentation/screens/message_detail_screen.dart'
|
||||
as _i5;
|
||||
import 'package:smol_mail/presentation/screens/onboarding_screen.dart' as _i6;
|
||||
import 'package:smol_mail/presentation/screens/settings_screen.dart' as _i7;
|
||||
as _i6;
|
||||
import 'package:smol_mail/presentation/screens/onboarding_screen.dart' as _i7;
|
||||
import 'package:smol_mail/presentation/screens/settings_screen.dart' as _i8;
|
||||
|
||||
/// generated route for
|
||||
/// [_i1.ComposeScreen]
|
||||
class ComposeRoute extends _i8.PageRouteInfo<ComposeRouteArgs> {
|
||||
class ComposeRoute extends _i9.PageRouteInfo<ComposeRouteArgs> {
|
||||
ComposeRoute({
|
||||
_i9.Key? key,
|
||||
_i10.Key? key,
|
||||
String? to,
|
||||
String? subject,
|
||||
List<_i8.PageRouteInfo>? children,
|
||||
List<_i9.PageRouteInfo>? children,
|
||||
}) : super(
|
||||
ComposeRoute.name,
|
||||
args: ComposeRouteArgs(key: key, to: to, subject: subject),
|
||||
|
|
@ -38,7 +39,7 @@ class ComposeRoute extends _i8.PageRouteInfo<ComposeRouteArgs> {
|
|||
|
||||
static const String name = 'ComposeRoute';
|
||||
|
||||
static _i8.PageInfo page = _i8.PageInfo(
|
||||
static _i9.PageInfo page = _i9.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
final args = data.argsAs<ComposeRouteArgs>(
|
||||
|
|
@ -56,7 +57,7 @@ class ComposeRoute extends _i8.PageRouteInfo<ComposeRouteArgs> {
|
|||
class ComposeRouteArgs {
|
||||
const ComposeRouteArgs({this.key, this.to, this.subject});
|
||||
|
||||
final _i9.Key? key;
|
||||
final _i10.Key? key;
|
||||
|
||||
final String? to;
|
||||
|
||||
|
|
@ -80,11 +81,11 @@ class ComposeRouteArgs {
|
|||
|
||||
/// generated route for
|
||||
/// [_i2.ContactDetailScreen]
|
||||
class ContactDetailRoute extends _i8.PageRouteInfo<ContactDetailRouteArgs> {
|
||||
class ContactDetailRoute extends _i9.PageRouteInfo<ContactDetailRouteArgs> {
|
||||
ContactDetailRoute({
|
||||
_i9.Key? key,
|
||||
_i10.Key? key,
|
||||
required String address,
|
||||
List<_i8.PageRouteInfo>? children,
|
||||
List<_i9.PageRouteInfo>? children,
|
||||
}) : super(
|
||||
ContactDetailRoute.name,
|
||||
args: ContactDetailRouteArgs(key: key, address: address),
|
||||
|
|
@ -93,7 +94,7 @@ class ContactDetailRoute extends _i8.PageRouteInfo<ContactDetailRouteArgs> {
|
|||
|
||||
static const String name = 'ContactDetailRoute';
|
||||
|
||||
static _i8.PageInfo page = _i8.PageInfo(
|
||||
static _i9.PageInfo page = _i9.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
final args = data.argsAs<ContactDetailRouteArgs>();
|
||||
|
|
@ -105,7 +106,7 @@ class ContactDetailRoute extends _i8.PageRouteInfo<ContactDetailRouteArgs> {
|
|||
class ContactDetailRouteArgs {
|
||||
const ContactDetailRouteArgs({this.key, required this.address});
|
||||
|
||||
final _i9.Key? key;
|
||||
final _i10.Key? key;
|
||||
|
||||
final String address;
|
||||
|
||||
|
|
@ -127,13 +128,13 @@ class ContactDetailRouteArgs {
|
|||
|
||||
/// generated route for
|
||||
/// [_i3.ContactsScreen]
|
||||
class ContactsRoute extends _i8.PageRouteInfo<void> {
|
||||
const ContactsRoute({List<_i8.PageRouteInfo>? children})
|
||||
class ContactsRoute extends _i9.PageRouteInfo<void> {
|
||||
const ContactsRoute({List<_i9.PageRouteInfo>? children})
|
||||
: super(ContactsRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'ContactsRoute';
|
||||
|
||||
static _i8.PageInfo page = _i8.PageInfo(
|
||||
static _i9.PageInfo page = _i9.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i3.ContactsScreen();
|
||||
|
|
@ -142,29 +143,45 @@ class ContactsRoute extends _i8.PageRouteInfo<void> {
|
|||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i4.InboxScreen]
|
||||
class InboxRoute extends _i8.PageRouteInfo<void> {
|
||||
const InboxRoute({List<_i8.PageRouteInfo>? children})
|
||||
: super(InboxRoute.name, initialChildren: children);
|
||||
/// [_i4.HomeScreen]
|
||||
class HomeRoute extends _i9.PageRouteInfo<void> {
|
||||
const HomeRoute({List<_i9.PageRouteInfo>? children})
|
||||
: super(HomeRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'InboxRoute';
|
||||
static const String name = 'HomeRoute';
|
||||
|
||||
static _i8.PageInfo page = _i8.PageInfo(
|
||||
static _i9.PageInfo page = _i9.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i4.InboxScreen();
|
||||
return const _i4.HomeScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i5.MessageDetailScreen]
|
||||
class MessageDetailRoute extends _i8.PageRouteInfo<MessageDetailRouteArgs> {
|
||||
/// [_i5.InboxScreen]
|
||||
class InboxRoute extends _i9.PageRouteInfo<void> {
|
||||
const InboxRoute({List<_i9.PageRouteInfo>? children})
|
||||
: super(InboxRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'InboxRoute';
|
||||
|
||||
static _i9.PageInfo page = _i9.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i5.InboxScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i6.MessageDetailScreen]
|
||||
class MessageDetailRoute extends _i9.PageRouteInfo<MessageDetailRouteArgs> {
|
||||
MessageDetailRoute({
|
||||
_i9.Key? key,
|
||||
_i10.Key? key,
|
||||
required String folder,
|
||||
required String id,
|
||||
List<_i8.PageRouteInfo>? children,
|
||||
List<_i9.PageRouteInfo>? children,
|
||||
}) : super(
|
||||
MessageDetailRoute.name,
|
||||
args: MessageDetailRouteArgs(key: key, folder: folder, id: id),
|
||||
|
|
@ -173,11 +190,11 @@ class MessageDetailRoute extends _i8.PageRouteInfo<MessageDetailRouteArgs> {
|
|||
|
||||
static const String name = 'MessageDetailRoute';
|
||||
|
||||
static _i8.PageInfo page = _i8.PageInfo(
|
||||
static _i9.PageInfo page = _i9.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
final args = data.argsAs<MessageDetailRouteArgs>();
|
||||
return _i5.MessageDetailScreen(
|
||||
return _i6.MessageDetailScreen(
|
||||
key: args.key,
|
||||
folder: args.folder,
|
||||
id: args.id,
|
||||
|
|
@ -193,7 +210,7 @@ class MessageDetailRouteArgs {
|
|||
required this.id,
|
||||
});
|
||||
|
||||
final _i9.Key? key;
|
||||
final _i10.Key? key;
|
||||
|
||||
final String folder;
|
||||
|
||||
|
|
@ -216,33 +233,33 @@ class MessageDetailRouteArgs {
|
|||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i6.OnboardingScreen]
|
||||
class OnboardingRoute extends _i8.PageRouteInfo<void> {
|
||||
const OnboardingRoute({List<_i8.PageRouteInfo>? children})
|
||||
/// [_i7.OnboardingScreen]
|
||||
class OnboardingRoute extends _i9.PageRouteInfo<void> {
|
||||
const OnboardingRoute({List<_i9.PageRouteInfo>? children})
|
||||
: super(OnboardingRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'OnboardingRoute';
|
||||
|
||||
static _i8.PageInfo page = _i8.PageInfo(
|
||||
static _i9.PageInfo page = _i9.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i6.OnboardingScreen();
|
||||
return const _i7.OnboardingScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i7.SettingsScreen]
|
||||
class SettingsRoute extends _i8.PageRouteInfo<void> {
|
||||
const SettingsRoute({List<_i8.PageRouteInfo>? children})
|
||||
/// [_i8.SettingsScreen]
|
||||
class SettingsRoute extends _i9.PageRouteInfo<void> {
|
||||
const SettingsRoute({List<_i9.PageRouteInfo>? children})
|
||||
: super(SettingsRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'SettingsRoute';
|
||||
|
||||
static _i8.PageInfo page = _i8.PageInfo(
|
||||
static _i9.PageInfo page = _i9.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i7.SettingsScreen();
|
||||
return const _i8.SettingsScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ class HomeGuard extends AutoRouteGuard {
|
|||
void onNavigation(NavigationResolver resolver, StackRouter router) {
|
||||
if (ref.read(identityProvider) != null &&
|
||||
ref.read(accountProvider) != null) {
|
||||
router.replace(InboxRoute());
|
||||
router.replace(HomeRoute());
|
||||
} else {
|
||||
resolver.next(true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,34 @@ class ContactDetailScreen extends ConsumerStatefulWidget {
|
|||
|
||||
class _ContactDetailScreenState extends ConsumerState<ContactDetailScreen> {
|
||||
bool resolving = false;
|
||||
bool tokenBusy = false;
|
||||
|
||||
// §5.8: admitting or withdrawing a contact's accept token pushes the
|
||||
// change to the server right away, since it only takes effect once the
|
||||
// server holds the changed set.
|
||||
Future<void> _toggleAccepted(bool currentlyAccepted) async {
|
||||
final client = ref.read(clientProvider);
|
||||
setState(() => tokenBusy = true);
|
||||
try {
|
||||
if (currentlyAccepted) {
|
||||
await client.blockContact(widget.address);
|
||||
} else {
|
||||
await client.acceptContact(widget.address);
|
||||
}
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(currentlyAccepted
|
||||
? "${widget.address} blocked; their mail now lands in requests"
|
||||
: "${widget.address} accepted; their mail now lands in your main tier")),
|
||||
);
|
||||
}
|
||||
} on SmolError catch (err) {
|
||||
if (mounted) showErrorSnackBar(context, err.message);
|
||||
} finally {
|
||||
if (mounted) setState(() => tokenBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _reResolve() async {
|
||||
final client = ref.read(clientProvider);
|
||||
|
|
@ -54,7 +82,11 @@ class _ContactDetailScreenState extends ConsumerState<ContactDetailScreen> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final contact = ref.watch(storeProvider).contact(widget.address);
|
||||
ref.watch(revisionProvider);
|
||||
final store = ref.read(storeProvider);
|
||||
final contact = store.contact(widget.address);
|
||||
final accepted = store.accepted(widget.address);
|
||||
final isAccepted = accepted != null && accepted.active;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.address)),
|
||||
|
|
@ -69,6 +101,29 @@ class _ContactDetailScreenState extends ConsumerState<ContactDetailScreen> {
|
|||
? "verified key"
|
||||
: "key pinned on first use"),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
"Accept token (SPEC.md §5.8)",
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
isAccepted
|
||||
? "This contact's mail lands in your main tier."
|
||||
: "This contact's mail lands in requests until accepted.",
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
tokenBusy
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: TextButton.icon(
|
||||
onPressed: () => _toggleAccepted(isAccepted),
|
||||
icon: Icon(isAccepted ? Icons.block : Icons.check_circle_outline,
|
||||
size: 18),
|
||||
label: Text(isAccepted ? "Block" : "Accept"),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 20),
|
||||
_field(context, "fingerprint", fingerprint(contact.key)),
|
||||
_field(context, "public key", b32encode(contact.key)),
|
||||
Row(
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ class ContactsScreen extends ConsumerWidget {
|
|||
itemCount: contacts.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final (address, contact) = contacts[i];
|
||||
final accepted = ref.read(storeProvider).accepted(address);
|
||||
return ListTile(
|
||||
splashColor: Theme.of(context).extension<AppColors>()!.highlight,
|
||||
onTap: () => AutoRouter.of(context)
|
||||
|
|
@ -93,12 +94,15 @@ class ContactsScreen extends ConsumerWidget {
|
|||
),
|
||||
trailing: Wrap(
|
||||
spacing: 6,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_chip(context, contact.verified ? "verified" : "tofu",
|
||||
ok: contact.verified),
|
||||
if (contact.history.isNotEmpty)
|
||||
_chip(context, "${contact.history.length} previous",
|
||||
ok: false),
|
||||
if (accepted != null && accepted.active)
|
||||
_chip(context, "accepted", ok: true),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
86
lib/presentation/screens/home_screen.dart
Normal file
86
lib/presentation/screens/home_screen.dart
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
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/presentation/theme/breakpoints.dart";
|
||||
import "package:smol_mail/shared/configs/flash_mail_icons.dart";
|
||||
|
||||
class _Destination {
|
||||
final Widget icon;
|
||||
final String label;
|
||||
|
||||
const _Destination({required this.icon, required this.label});
|
||||
}
|
||||
|
||||
/// The app's only persistent navigation surface: a bottom [NavigationBar] on
|
||||
/// phone-width screens, a side [NavigationRail] on desktop-width ones — no
|
||||
/// hamburger drawer at either width. Mail/Contacts/Settings are switched by
|
||||
/// tapping only, never by swipe, so a horizontal gesture inside a
|
||||
/// destination (e.g. the Inbox/Sent/Requests tab bar) never changes section.
|
||||
@RoutePage()
|
||||
class HomeScreen extends ConsumerWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final unread = ref.watch(unreadProvider) + ref.watch(requestsUnreadProvider);
|
||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||
|
||||
final mailIcon = Icon(FlashMailIcons.inbox);
|
||||
final destinations = [
|
||||
_Destination(
|
||||
icon: unread > 0 ? Badge(label: Text("$unread"), child: mailIcon) : mailIcon,
|
||||
label: "Mail",
|
||||
),
|
||||
const _Destination(icon: Icon(Icons.contacts), label: "Contacts"),
|
||||
const _Destination(icon: Icon(Icons.settings), label: "Settings"),
|
||||
];
|
||||
|
||||
return AutoTabsRouter.builder(
|
||||
routes: const [InboxRoute(), ContactsRoute(), SettingsRoute()],
|
||||
builder: (context, children, tabsRouter) {
|
||||
final body = IndexedStack(index: tabsRouter.activeIndex, children: children);
|
||||
final wide = MediaQuery.sizeOf(context).width >= kDesktopBreakpoint;
|
||||
|
||||
if (!wide) {
|
||||
return Scaffold(
|
||||
body: body,
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: tabsRouter.activeIndex,
|
||||
onDestinationSelected: tabsRouter.setActiveIndex,
|
||||
indicatorColor: appColors.highlight,
|
||||
destinations: [
|
||||
for (final d in destinations)
|
||||
NavigationDestination(icon: d.icon, label: d.label),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
NavigationRail(
|
||||
selectedIndex: tabsRouter.activeIndex,
|
||||
onDestinationSelected: tabsRouter.setActiveIndex,
|
||||
backgroundColor: appColors.cardFill,
|
||||
useIndicator: true,
|
||||
indicatorColor: appColors.highlight,
|
||||
labelType: NavigationRailLabelType.all,
|
||||
destinations: [
|
||||
for (final d in destinations)
|
||||
NavigationRailDestination(icon: d.icon, label: Text(d.label)),
|
||||
],
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(child: body),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,18 +4,46 @@ 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/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";
|
||||
|
||||
const _folders = ["inbox", "requests", "sent"];
|
||||
|
||||
/// No server push in v1 (SPEC.md §13): new mail arrives when the user taps
|
||||
/// fetch, which pulls everything and acknowledges what verifies.
|
||||
/// fetch, which pulls everything and acknowledges what verifies. One FETCH
|
||||
/// call populates both Inbox and Requests (they're tiers of the same
|
||||
/// mailbox), so both — but not the purely local Sent folder — offer fetch.
|
||||
@RoutePage()
|
||||
class InboxScreen extends ConsumerWidget {
|
||||
class InboxScreen extends ConsumerStatefulWidget {
|
||||
const InboxScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<InboxScreen> createState() => _InboxScreenState();
|
||||
}
|
||||
|
||||
class _InboxScreenState extends ConsumerState<InboxScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController _tabController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: _folders.length, vsync: this)
|
||||
..addListener(() {
|
||||
// Fires once settled, whether the change came from a tap or a swipe;
|
||||
// only the AppBar's fetch button depends on which tab is active.
|
||||
if (!_tabController.indexIsChanging) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _fetch(BuildContext context, WidgetRef ref) async {
|
||||
final client = ref.read(clientProvider);
|
||||
try {
|
||||
|
|
@ -36,47 +64,63 @@ class InboxScreen extends ConsumerWidget {
|
|||
}
|
||||
}
|
||||
|
||||
Widget _folderView(BuildContext context, WidgetRef ref, String folder) {
|
||||
final messages = ref.watch(messagesForFolderProvider(folder));
|
||||
final canFetch = folder != "sent";
|
||||
final onRefresh = canFetch ? () => _fetch(context, ref) : () => Future.value();
|
||||
|
||||
if (messages.isEmpty) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: onRefresh,
|
||||
child: ListView(
|
||||
children: const [
|
||||
ImageBanner(
|
||||
imgSrc: "assets/images/empty.png",
|
||||
text: "Nothing here yet!",
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return MessageList(folder: folder, onRefresh: onRefresh);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final messages = ref.watch(messagesProvider);
|
||||
final folder = ref.watch(folderProvider);
|
||||
Widget build(BuildContext context) {
|
||||
final unread = ref.watch(unreadProvider);
|
||||
final requestsUnread = ref.watch(requestsUnreadProvider);
|
||||
final canFetch = _folders[_tabController.index] != "sent";
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(folder == "inbox" ? "Inbox" : "Sent"),
|
||||
title: const Text("kirakira"),
|
||||
actions: [
|
||||
if (folder == "inbox")
|
||||
if (canFetch)
|
||||
IconButton(
|
||||
tooltip: "Fetch",
|
||||
onPressed: () => _fetch(context, ref),
|
||||
icon: const Icon(Icons.cloud_download),
|
||||
),
|
||||
],
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
tabs: [
|
||||
Tab(text: unread > 0 ? "Inbox ($unread)" : "Inbox"),
|
||||
Tab(text: requestsUnread > 0 ? "Requests ($requestsUnread)" : "Requests"),
|
||||
const Tab(text: "Sent"),
|
||||
],
|
||||
),
|
||||
),
|
||||
drawer: const AppDrawer(),
|
||||
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(),
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
for (final folder in _folders) _folderView(context, ref, folder),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,10 @@ 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";
|
||||
import "package:smol_mail/smol/store.dart";
|
||||
|
||||
/// Fetched mail is acknowledged off the server during fetch, so deleting here
|
||||
/// removes only the local sealed copy.
|
||||
/// Deleting removes the local copy, and the server's too if "leave mail on
|
||||
/// server" left one there to remove (settings; SPEC.md §10).
|
||||
@RoutePage()
|
||||
class MessageDetailScreen extends ConsumerWidget {
|
||||
final String folder;
|
||||
|
|
@ -24,8 +25,14 @@ class MessageDetailScreen extends ConsumerWidget {
|
|||
required this.id,
|
||||
});
|
||||
|
||||
Future<void> _delete(BuildContext context, WidgetRef ref) async {
|
||||
await ref.read(storeProvider).deleteMessage(folder, id);
|
||||
Future<void> _delete(
|
||||
BuildContext context, WidgetRef ref, MailRecord record) async {
|
||||
try {
|
||||
await ref.read(clientProvider).deleteMessage(folder, record);
|
||||
} on SmolError catch (err) {
|
||||
if (context.mounted) showErrorSnackBar(context, err.message);
|
||||
return; // keep the local copy if a needed server delete failed
|
||||
}
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
if (context.mounted) {
|
||||
AutoRouter.of(context).pop();
|
||||
|
|
@ -109,8 +116,8 @@ class MessageDetailScreen extends ConsumerWidget {
|
|||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: "Delete local copy",
|
||||
onPressed: () => _delete(context, ref),
|
||||
tooltip: "Delete",
|
||||
onPressed: () => _delete(context, ref, record),
|
||||
icon: const Icon(Icons.delete),
|
||||
),
|
||||
],
|
||||
|
|
@ -118,6 +125,7 @@ class MessageDetailScreen extends ConsumerWidget {
|
|||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
child: MessageView(
|
||||
folder: folder,
|
||||
record: record,
|
||||
opened: opened,
|
||||
onNameSender: opened.sender == null
|
||||
|
|
|
|||
|
|
@ -29,12 +29,17 @@ enum _Step { welcome, backup, restore, register }
|
|||
|
||||
class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
||||
_Step step = _Step.welcome;
|
||||
Uint8List? createdSeed;
|
||||
Uint8List? createdMaster;
|
||||
bool busy = false;
|
||||
// The register step doubles as "pin a key to finish recalling" when a
|
||||
// restore's recall can't proceed without one yet — same fields, different
|
||||
// framing and default action, not the generic "register a new address" copy.
|
||||
bool recallIntent = false;
|
||||
// Set alongside recallIntent when we got here from Restore rather than from
|
||||
// "Already registered? Recall": there is no well-defined "register a new
|
||||
// address instead" fallback for a restored master until its rotation index
|
||||
// is known, so that escape hatch is hidden in this case (§2).
|
||||
bool restoreIntent = false;
|
||||
|
||||
final seedController = TextEditingController();
|
||||
final restoreAddressController = TextEditingController();
|
||||
|
|
@ -62,11 +67,11 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
|||
}
|
||||
|
||||
// Reaching onboarding at all means HomeGuard already found no complete
|
||||
// identity+account, so a seed still sitting in the store here can only be
|
||||
// an abandoned attempt from earlier in this same flow (wrong seed, failed
|
||||
// identity+account, so a master still sitting in the store here can only be
|
||||
// an abandoned attempt from earlier in this same flow (wrong master, failed
|
||||
// recall, "Back") — safe to replace rather than reject.
|
||||
// wipe() is fire-and-forget here, like every other store write in this
|
||||
// screen (setIdentity, pinServer, ...) — Hive updates its in-memory state
|
||||
// screen (setMaster, pinServer, ...) — Hive updates its in-memory state
|
||||
// synchronously and persists to disk in the background, so the identity
|
||||
// check right after is already consistent without awaiting the write.
|
||||
void _clearAbandonedIdentity() {
|
||||
|
|
@ -80,9 +85,9 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
|||
_clearAbandonedIdentity();
|
||||
final client = ref.read(clientProvider);
|
||||
try {
|
||||
final fresh = client.createIdentity();
|
||||
final master = client.createIdentity();
|
||||
setState(() {
|
||||
createdSeed = fresh.seed;
|
||||
createdMaster = master;
|
||||
step = _Step.backup;
|
||||
});
|
||||
} on Exception catch (err) {
|
||||
|
|
@ -94,74 +99,49 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
|||
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() {
|
||||
_clearAbandonedIdentity();
|
||||
final client = ref.read(clientProvider);
|
||||
try {
|
||||
client.restoreIdentity(seedController.text);
|
||||
} on Exception catch (err) {
|
||||
_showError(err);
|
||||
return;
|
||||
}
|
||||
// §2: a master alone does not say which rotation index a server bound, so
|
||||
// restoring resolves the address and walks indices to find it — restore
|
||||
// always ends in a recall, never a bare local step.
|
||||
Future<void> _submitRestore() async {
|
||||
final addressText = restoreAddressController.text.trim();
|
||||
if (addressText.isEmpty) {
|
||||
setState(() => step = _Step.register);
|
||||
_showError(const SmolError("enter the address this master was registered under"));
|
||||
return;
|
||||
}
|
||||
_clearAbandonedIdentity();
|
||||
// The register step has its own address field (it also needs a server
|
||||
// key, which restore doesn't collect) — carry over what was already
|
||||
// typed rather than making the user re-enter it.
|
||||
addressController.text = addressText;
|
||||
final SmolAddress addr;
|
||||
final client = ref.read(clientProvider);
|
||||
setState(() => busy = true);
|
||||
try {
|
||||
addr = parseAddress(addressText);
|
||||
await client.restoreAndRecall(seedController.text, addressText);
|
||||
if (!mounted) return;
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
AutoRouter.of(context).replace(HomeRoute());
|
||||
} on Exception catch (err) {
|
||||
// A genuine typo, distinct from the merely-unpinned case below — still
|
||||
// worth an error, but land on the same recall-oriented step to fix it.
|
||||
if (!mounted) return;
|
||||
// Most often the host just isn't pinned yet (SPEC.md §4) — the expected
|
||||
// state right after a restore, not a dead end — so land on the
|
||||
// recall-framed register step to collect a key and retry.
|
||||
_showError(err);
|
||||
setState(() {
|
||||
recallIntent = true;
|
||||
restoreIntent = true;
|
||||
step = _Step.register;
|
||||
});
|
||||
return;
|
||||
} finally {
|
||||
if (mounted) setState(() => busy = false);
|
||||
}
|
||||
// Recall needs the host pinned first (SPEC.md §4). That's the expected,
|
||||
// common state right after a restore — not an error — so check for it
|
||||
// up front instead of letting recallAccount fail and surfacing that as
|
||||
// one: this address just needs a key before its first recall can proceed.
|
||||
if (ref.read(storeProvider).serverPin(addr.host) == null) {
|
||||
setState(() {
|
||||
recallIntent = true;
|
||||
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(() {
|
||||
recallIntent = true;
|
||||
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.
|
||||
// Recall binds a restored or already-created identity to its registered
|
||||
// address without re-REGISTER.
|
||||
Future<void> _submitRecall() async {
|
||||
await _submit(recall: true);
|
||||
}
|
||||
|
|
@ -178,7 +158,9 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
|||
if (serverKey.isNotEmpty) {
|
||||
client.pinServer(address.host, serverKey);
|
||||
}
|
||||
if (recall) {
|
||||
if (recall && restoreIntent) {
|
||||
await client.restoreAndRecall(seedController.text, address.short);
|
||||
} else if (recall) {
|
||||
await client.recallAccount(address.short);
|
||||
} else {
|
||||
await client.registerAccount(address.short,
|
||||
|
|
@ -186,7 +168,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
|||
}
|
||||
if (mounted) {
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
AutoRouter.of(context).replace(InboxRoute());
|
||||
AutoRouter.of(context).replace(HomeRoute());
|
||||
}
|
||||
} catch (err) {
|
||||
_showError(err);
|
||||
|
|
@ -248,20 +230,21 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
|||
}
|
||||
|
||||
Widget _backup(BuildContext context) {
|
||||
final seed = createdSeed!;
|
||||
final seedHex = hex(seed);
|
||||
final master = createdMaster!;
|
||||
final masterHex = hex(master);
|
||||
final publicKey = ref.read(clientProvider).identity!.publicKey;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_header(context, "Back up this seed; it is the only secret."),
|
||||
_header(context, "Back up this master secret; it is the only secret."),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
"fingerprint:\n${fingerprint(ed25519PublicKey(seed))}",
|
||||
"fingerprint:\n${fingerprint(publicKey)}",
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
SelectableText(
|
||||
seedHex,
|
||||
masterHex,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall!
|
||||
|
|
@ -269,14 +252,14 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
|||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: seedHex));
|
||||
await Clipboard.setData(ClipboardData(text: masterHex));
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||
const SnackBar(content: Text("Seed copied to clipboard")),
|
||||
const SnackBar(content: Text("Master secret copied to clipboard")),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text("Copy seed"),
|
||||
child: const Text("Copy master secret"),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
PrimaryButton(
|
||||
|
|
@ -292,7 +275,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
|||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_header(context, "Enter the 32-byte seed as 64 hex characters."),
|
||||
_header(context, "Enter the 32-byte master secret as 64 hex characters."),
|
||||
const SizedBox(height: 20),
|
||||
TextField(
|
||||
controller: seedController,
|
||||
|
|
@ -306,7 +289,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
|||
TextField(
|
||||
controller: restoreAddressController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Address (if already registered)",
|
||||
labelText: "Address this master was registered under",
|
||||
hintText: "alice@example.org",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
|
|
@ -314,13 +297,13 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
|||
),
|
||||
const SizedBox(height: 30),
|
||||
PrimaryButton(
|
||||
onPressed: _submitRestore,
|
||||
child: const Text("Restore"),
|
||||
onPressed: busy ? () {} : _submitRestore,
|
||||
child: busy ? const SmallLoadingSpinner() : const Text("Restore"),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
SecondaryButton(
|
||||
text: "Back",
|
||||
onPressed: () => setState(() => step = _Step.welcome),
|
||||
onPressed: busy ? () {} : () => setState(() => step = _Step.welcome),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
|
|
@ -377,14 +360,18 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
|||
? const SmallLoadingSpinner()
|
||||
: Text(recallIntent ? "Pin and Recall" : "Pin and Register"),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: busy
|
||||
? () {}
|
||||
: (recallIntent ? _submitRegistration : _submitRecall),
|
||||
child: Text(recallIntent
|
||||
? "Register a new address instead"
|
||||
: "Already registered? Recall"),
|
||||
),
|
||||
// Restoring a master has no well-defined "register instead" fallback
|
||||
// until its rotation index is resolved (§2), so that escape hatch is
|
||||
// only offered from the fresh-identity path.
|
||||
if (!restoreIntent)
|
||||
TextButton(
|
||||
onPressed: busy
|
||||
? () {}
|
||||
: (recallIntent ? _submitRegistration : _submitRecall),
|
||||
child: Text(recallIntent
|
||||
? "Register a new address instead"
|
||||
: "Already registered? Recall"),
|
||||
),
|
||||
const SizedBox(height: 80),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -88,8 +88,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
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("sent").length;
|
||||
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, "
|
||||
|
|
@ -207,7 +208,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
final account = ref.watch(accountProvider);
|
||||
if (me == null) return Scaffold(appBar: AppBar(title: const Text("Settings")));
|
||||
|
||||
final seedHex = hex(me.seed);
|
||||
final masterHex = hex(store.master()!);
|
||||
final share = account?.uri(me.publicKey);
|
||||
final pins = store.allPins();
|
||||
|
||||
|
|
@ -249,12 +250,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
context,
|
||||
borderColor: Theme.of(context).colorScheme.error,
|
||||
children: [
|
||||
Text("seed", style: Theme.of(context).textTheme.labelSmall),
|
||||
Text("master secret", style: Theme.of(context).textTheme.labelSmall),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SelectableText(
|
||||
seedShown ? seedHex : "•" * 64,
|
||||
seedShown ? masterHex : "•" * 64,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
|
|
@ -266,12 +267,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
IconButton(
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
onPressed: () =>
|
||||
Clipboard.setData(ClipboardData(text: seedHex)),
|
||||
Clipboard.setData(ClipboardData(text: masterHex)),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
"back this seed up; it is the only secret",
|
||||
"back this master secret up; it is the only secret",
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelSmall!
|
||||
|
|
@ -279,12 +280,31 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
"retired keys: ${store.identities().length - 1} (kept to read old mail)",
|
||||
"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),
|
||||
|
|
|
|||
4
lib/presentation/theme/breakpoints.dart
Normal file
4
lib/presentation/theme/breakpoints.dart
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/// Below this logical width the home shell shows a bottom [NavigationBar]
|
||||
/// (phone); at or above it, a side [NavigationRail] (desktop). Matches
|
||||
/// Material 3's compact/medium width class boundary.
|
||||
const double kDesktopBreakpoint = 600;
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
import "package:smol_mail/data/providers/providers.dart";
|
||||
import "package:smol_mail/smol/proto.dart";
|
||||
import "package:smol_mail/presentation/widgets/drawer/identity_header.dart";
|
||||
import "package:smol_mail/presentation/widgets/drawer/drawer_list.dart";
|
||||
import "package:smol_mail/presentation/theme/app_colors.dart";
|
||||
|
||||
class AppDrawer extends ConsumerWidget {
|
||||
const AppDrawer({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final identity = ref.watch(identityProvider);
|
||||
final account = ref.watch(accountProvider);
|
||||
final address = account?.short ?? "";
|
||||
final fp = identity == null ? "" : fingerprint(identity.publicKey);
|
||||
|
||||
return Drawer(
|
||||
backgroundColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
IdentityHeader(address: address, fingerprint: fp),
|
||||
const SizedBox(height: 50),
|
||||
const DrawerList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
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:auto_route/auto_route.dart";
|
||||
import "package:smol_mail/shared/configs/flash_mail_icons.dart";
|
||||
import "package:smol_mail/presentation/widgets/drawer/drawer_list_tile.dart";
|
||||
|
||||
class DrawerList extends ConsumerWidget {
|
||||
const DrawerList({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final unread = ref.watch(unreadProvider);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 25),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
DrawerListTile(
|
||||
color: Theme.of(context).primaryColor,
|
||||
icon: FlashMailIcons.inbox,
|
||||
title: "Inbox${unread > 0 ? " ($unread)" : ""}",
|
||||
onTap: () {
|
||||
ref.read(folderProvider.notifier).select("inbox");
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
DrawerListTile(
|
||||
color: Theme.of(context).primaryColor,
|
||||
icon: Icons.outgoing_mail,
|
||||
title: "Sent",
|
||||
onTap: () {
|
||||
ref.read(folderProvider.notifier).select("sent");
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
DrawerListTile(
|
||||
color: Theme.of(context).primaryColor,
|
||||
icon: Icons.contacts,
|
||||
title: "Contacts",
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
AutoRouter.of(context).push(ContactsRoute());
|
||||
},
|
||||
),
|
||||
DrawerListTile(
|
||||
color: Theme.of(context).primaryColor,
|
||||
icon: Icons.settings,
|
||||
title: "Settings",
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
AutoRouter.of(context).push(SettingsRoute());
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import "package:flutter/material.dart";
|
||||
|
||||
import "package:smol_mail/presentation/theme/app_colors.dart";
|
||||
|
||||
class DrawerListTile extends StatelessWidget {
|
||||
final Color color;
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final void Function() onTap;
|
||||
|
||||
const DrawerListTile({
|
||||
super.key,
|
||||
required this.color,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
splashColor: appColors.highlight,
|
||||
highlightColor: appColors.cardFill,
|
||||
),
|
||||
child: ListTile(
|
||||
onTap: onTap,
|
||||
leading: Icon(icon, color: color, size: 18),
|
||||
title: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontFamily: "Inter",
|
||||
color: color,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
import "package:flutter/material.dart";
|
||||
|
||||
import "package:smol_mail/shared/utils/format.dart";
|
||||
|
||||
/// Identity summary in the drawer: the address is what gets shared, the
|
||||
/// fingerprint is what gets verified.
|
||||
class IdentityHeader extends StatelessWidget {
|
||||
final String address;
|
||||
final String fingerprint;
|
||||
|
||||
const IdentityHeader({
|
||||
super.key,
|
||||
required this.address,
|
||||
required this.fingerprint,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
CircleAvatar(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
radius: 35,
|
||||
child: Text(
|
||||
address.isEmpty ? "?" : address[0].toUpperCase(),
|
||||
style:
|
||||
Theme.of(context).textTheme.bodyLarge!.copyWith(fontSize: 30),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
InkWell(
|
||||
onTap: () => copyText(context, address),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
address.isEmpty ? "(not registered)" : address,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
InkWell(
|
||||
onTap: () => copyText(context, fingerprint),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
"fp: $fingerprint",
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,21 +5,23 @@ import "package:smol_mail/data/providers/providers.dart";
|
|||
import "package:smol_mail/presentation/widgets/message/message_tile.dart";
|
||||
|
||||
class MessageList extends ConsumerWidget {
|
||||
final String folder;
|
||||
final Future<void> Function() onRefresh;
|
||||
|
||||
const MessageList({
|
||||
super.key,
|
||||
required this.folder,
|
||||
required this.onRefresh,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final messages = ref.watch(messagesProvider);
|
||||
final messages = ref.watch(messagesForFolderProvider(folder));
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: onRefresh,
|
||||
child: ListView.builder(
|
||||
itemBuilder: (ctx, i) => MessageTile(record: messages[i]),
|
||||
itemBuilder: (ctx, i) => MessageTile(folder: folder, record: messages[i]),
|
||||
itemCount: messages.length,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,13 +11,13 @@ import "package:smol_mail/smol/proto.dart";
|
|||
import "package:smol_mail/smol/store.dart";
|
||||
|
||||
class MessageTile extends ConsumerWidget {
|
||||
final String folder;
|
||||
final MailRecord record;
|
||||
|
||||
const MessageTile({super.key, required this.record});
|
||||
const MessageTile({super.key, required this.folder, required this.record});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final folder = ref.watch(folderProvider);
|
||||
final store = ref.watch(storeProvider);
|
||||
final opened = ref.read(clientProvider).describe(record);
|
||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||
|
|
@ -31,7 +31,7 @@ class MessageTile extends ConsumerWidget {
|
|||
} else {
|
||||
who = "?";
|
||||
}
|
||||
final unread = folder == "inbox" && !store.isRead(record.id);
|
||||
final unread = folder != "sent" && !store.isRead(record.id);
|
||||
final errorText = opened.error != null ? "<unreadable>" : null;
|
||||
final subject =
|
||||
errorText ?? (opened.subject.isEmpty ? "(no subject)" : opened.subject);
|
||||
|
|
@ -44,7 +44,7 @@ class MessageTile extends ConsumerWidget {
|
|||
splashColor: appColors.highlight,
|
||||
focusColor: appColors.cardFill,
|
||||
onTap: () {
|
||||
if (folder == "inbox") {
|
||||
if (folder != "sent") {
|
||||
store.markRead(record.id);
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,12 +15,14 @@ import "package:smol_mail/smol/store.dart";
|
|||
/// The opened message: trust row (fingerprint and how the sender's key is
|
||||
/// known), frontmatter fields, and the plain body — smol mail has no HTML.
|
||||
class MessageView extends ConsumerWidget {
|
||||
final String folder;
|
||||
final MailRecord record;
|
||||
final OpenedRecord opened;
|
||||
final void Function(Uint8List senderKey)? onNameSender;
|
||||
|
||||
const MessageView({
|
||||
super.key,
|
||||
required this.folder,
|
||||
required this.record,
|
||||
required this.opened,
|
||||
this.onNameSender,
|
||||
|
|
@ -38,18 +40,19 @@ class MessageView extends ConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
final folder = ref.watch(folderProvider);
|
||||
final store = ref.watch(storeProvider);
|
||||
final client = ref.read(clientProvider);
|
||||
|
||||
// Sent copies carry no signature from a third party; the trust row then
|
||||
// just says what they are.
|
||||
final isSent = folder == "sent";
|
||||
final isRequest = folder == "requests";
|
||||
final senderKey = opened.sender;
|
||||
final known = senderKey == null ? null : store.addressForKey(senderKey);
|
||||
final contact = known == null ? null : store.contact(known);
|
||||
final verifiedSender = contact?.verified ?? false;
|
||||
final replyTo = isSent ? null : client.replyAddress(opened);
|
||||
final accepted = known == null ? null : store.accepted(known);
|
||||
|
||||
final subject =
|
||||
opened.subject.isEmpty ? "(no subject)" : opened.subject;
|
||||
|
|
@ -69,6 +72,10 @@ class MessageView extends ConsumerWidget {
|
|||
: "key bound to no address",
|
||||
alert: known == null,
|
||||
),
|
||||
if (isRequest) ...[
|
||||
const SizedBox(height: 10),
|
||||
_badge(context, "arrived without an accept token (SPEC.md §5.8)", alert: true),
|
||||
],
|
||||
if (!isSent && senderKey != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
_keyBlock(context, "${fingerprint(senderKey)}\n${b32encode(senderKey)}"),
|
||||
|
|
@ -78,7 +85,7 @@ class MessageView extends ConsumerWidget {
|
|||
isSent ? (record.recipient ?? "") : (known ?? "unknown sender")),
|
||||
_field(context, "Date", formatTime(opened.time)),
|
||||
for (final entry in opened.fields.entries)
|
||||
if (entry.key != "Subject" && entry.key != "Reply-To")
|
||||
if (entry.key != "subject" && entry.key != "reply-to" && entry.key != "accept")
|
||||
_field(context, entry.key, entry.value),
|
||||
if (!isSent && replyTo != null)
|
||||
Row(
|
||||
|
|
@ -114,6 +121,27 @@ class MessageView extends ConsumerWidget {
|
|||
child: const Text("Name this sender"),
|
||||
),
|
||||
),
|
||||
if (isRequest && known != null && !(accepted?.active ?? false))
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
icon: const Icon(Icons.check_circle_outline, size: 18),
|
||||
label: const Text("Accept sender"),
|
||||
onPressed: () async {
|
||||
try {
|
||||
await client.acceptContact(known);
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("$known accepted")),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (context.mounted) showErrorSnackBar(context, err.toString());
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 20),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue