394 lines
12 KiB
Dart
394 lines
12 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:intl/intl.dart';
|
|
|
|
import 'package:sharedinbox/core/models/account.dart';
|
|
import 'package:sharedinbox/core/models/email.dart';
|
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
|
import 'package:sharedinbox/di.dart';
|
|
|
|
final _dateFmt = DateFormat('MMM d');
|
|
final _formattedDates = <int, String>{};
|
|
|
|
int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
|
|
|
|
String _fmtDate(DateTime dt) =>
|
|
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
|
|
|
|
class CombinedInboxScreen extends ConsumerStatefulWidget {
|
|
const CombinedInboxScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<CombinedInboxScreen> createState() =>
|
|
_CombinedInboxScreenState();
|
|
}
|
|
|
|
class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
|
|
static const _pageSize = 50;
|
|
int _limit = _pageSize;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final accountsAsync = ref.watch(allAccountsProvider);
|
|
|
|
return accountsAsync.when(
|
|
loading: () => const Scaffold(
|
|
body: Center(child: CircularProgressIndicator()),
|
|
),
|
|
error: (e, _) => Scaffold(
|
|
body: Center(child: Text('Error: $e')),
|
|
),
|
|
data: (accounts) {
|
|
if (accounts.isEmpty) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (context.mounted) context.go('/accounts');
|
|
});
|
|
return const Scaffold(
|
|
body: Center(child: CircularProgressIndicator()),
|
|
);
|
|
}
|
|
|
|
final accountNames = {
|
|
for (final a in accounts) a.id: a.displayName,
|
|
};
|
|
final showAccount = accounts.length > 1;
|
|
|
|
return Scaffold(
|
|
appBar: _buildAppBar(accounts),
|
|
drawer: _buildDrawer(context, accounts),
|
|
body: _buildBody(accountNames, showAccount),
|
|
floatingActionButton: FloatingActionButton(
|
|
onPressed: () => context.push('/compose'),
|
|
child: const Icon(Icons.edit),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
PreferredSizeWidget _buildAppBar(List<Account> accounts) {
|
|
return AppBar(
|
|
title: const Text('Combined Inbox'),
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.search),
|
|
tooltip: 'Search',
|
|
onPressed: () => context.push('/search'),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.sync),
|
|
tooltip: 'Sync all',
|
|
onPressed: () {
|
|
for (final a in accounts) {
|
|
ref.read(syncManagerProvider).syncNow(a.id);
|
|
}
|
|
},
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildDrawer(BuildContext context, List<Account> accounts) {
|
|
return Drawer(
|
|
child: ListView(
|
|
padding: EdgeInsets.zero,
|
|
children: [
|
|
const DrawerHeader(
|
|
decoration: BoxDecoration(color: Colors.blueGrey),
|
|
child: Text(
|
|
'sharedinbox.de',
|
|
style: TextStyle(color: Colors.white, fontSize: 24),
|
|
),
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.manage_accounts),
|
|
title: const Text('Accounts'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
context.go('/accounts');
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.person_add),
|
|
title: const Text('Add account'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
unawaited(context.push('/accounts/add'));
|
|
},
|
|
),
|
|
const Divider(),
|
|
for (final account in accounts)
|
|
ListTile(
|
|
leading: const Icon(Icons.inbox),
|
|
title: Text(account.displayName),
|
|
subtitle: Text(account.email),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
unawaited(context.push('/accounts/${account.id}/mailboxes'));
|
|
},
|
|
),
|
|
const Divider(),
|
|
ListTile(
|
|
leading: const Icon(Icons.settings),
|
|
title: const Text('Preferences'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
unawaited(context.push('/accounts/preferences'));
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.history),
|
|
title: const Text('Undo Log'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
unawaited(context.push('/accounts/undo-log'));
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.info_outline),
|
|
title: const Text('About'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
unawaited(context.push('/accounts/about'));
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildBody(Map<String, String> accountNames, bool showAccount) {
|
|
final emailRepo = ref.watch(emailRepositoryProvider);
|
|
return RefreshIndicator(
|
|
onRefresh: () async {
|
|
final accounts = ref.read(allAccountsProvider).value ?? [];
|
|
for (final a in accounts) {
|
|
ref.read(syncManagerProvider).syncNow(a.id);
|
|
}
|
|
},
|
|
child: StreamBuilder<List<EmailThread>>(
|
|
stream: emailRepo.observeAllInboxThreads(limit: _limit),
|
|
builder: (ctx, snap) {
|
|
if (!snap.hasData) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
final threads = snap.data!;
|
|
if (threads.isEmpty) {
|
|
return ListView(
|
|
children: const [
|
|
SizedBox(
|
|
height: 300,
|
|
child: Center(child: Text('No emails')),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
return _buildThreadList(threads, accountNames, showAccount);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildThreadList(
|
|
List<EmailThread> threads,
|
|
Map<String, String> accountNames,
|
|
bool showAccount,
|
|
) {
|
|
final hasMore = threads.length == _limit;
|
|
return ListView.builder(
|
|
itemCount: threads.length + (hasMore ? 1 : 0),
|
|
itemBuilder: (ctx, i) {
|
|
if (i == threads.length) {
|
|
return TextButton(
|
|
onPressed: () => setState(() => _limit += _pageSize),
|
|
child: const Text('Load more'),
|
|
);
|
|
}
|
|
return _buildThreadTile(ctx, threads[i], accountNames, showAccount);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildThreadTile(
|
|
BuildContext ctx,
|
|
EmailThread t,
|
|
Map<String, String> accountNames,
|
|
bool showAccount,
|
|
) {
|
|
final senderNames =
|
|
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
|
|
|
|
final tile = ListTile(
|
|
leading: Icon(
|
|
t.hasUnread ? Icons.mail : Icons.mail_outline,
|
|
color: t.hasUnread ? Theme.of(ctx).colorScheme.primary : null,
|
|
),
|
|
title: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
senderNames.isEmpty ? '(unknown)' : senderNames,
|
|
style: t.hasUnread
|
|
? const TextStyle(fontWeight: FontWeight.bold)
|
|
: null,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
if (t.messageCount > 1)
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 4),
|
|
child: Text(
|
|
'[${t.messageCount}]',
|
|
style: Theme.of(ctx).textTheme.bodySmall,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
subtitle: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
t.subject ?? '(no subject)',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: t.hasUnread
|
|
? const TextStyle(fontWeight: FontWeight.bold)
|
|
: null,
|
|
),
|
|
if (t.preview != null && t.preview!.isNotEmpty)
|
|
Text(
|
|
t.preview!,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(ctx).textTheme.bodySmall,
|
|
),
|
|
if (showAccount)
|
|
Text(
|
|
accountNames[t.accountId] ?? t.accountId,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
|
|
color: Theme.of(ctx).colorScheme.primary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
trailing: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (t.isFlagged)
|
|
const Icon(Icons.star, color: Colors.amber, size: 16),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
_fmtDate(t.latestDate),
|
|
style: Theme.of(ctx).textTheme.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
onTap: t.messageCount > 1
|
|
? () => context.push(
|
|
'/accounts/${t.accountId}/mailboxes'
|
|
'/${Uri.encodeComponent(t.mailboxPath)}'
|
|
'/threads/${Uri.encodeComponent(t.threadId)}',
|
|
)
|
|
: () => context.push(
|
|
'/accounts/${t.accountId}/mailboxes'
|
|
'/${Uri.encodeComponent(t.mailboxPath)}'
|
|
'/emails/${Uri.encodeComponent(t.latestEmailId)}',
|
|
),
|
|
);
|
|
|
|
return Dismissible(
|
|
key: ValueKey('${t.accountId}:${t.threadId}'),
|
|
background: _swipeBackground(
|
|
alignment: Alignment.centerLeft,
|
|
color: Colors.green,
|
|
icon: Icons.archive,
|
|
label: 'Archive',
|
|
),
|
|
secondaryBackground: _swipeBackground(
|
|
alignment: Alignment.centerRight,
|
|
color: Colors.red,
|
|
icon: Icons.delete,
|
|
label: 'Delete',
|
|
),
|
|
onDismissed: (direction) => unawaited(_onSwipeDismissed(t, direction)),
|
|
child: tile,
|
|
);
|
|
}
|
|
|
|
Future<void> _onSwipeDismissed(
|
|
EmailThread t,
|
|
DismissDirection direction,
|
|
) async {
|
|
final repo = ref.read(emailRepositoryProvider);
|
|
|
|
final originalEmails = (await Future.wait(
|
|
t.emailIds.map((id) => repo.getEmail(id)),
|
|
))
|
|
.whereType<Email>()
|
|
.toList();
|
|
|
|
if (direction == DismissDirection.startToEnd) {
|
|
final archive = await ref
|
|
.read(mailboxRepositoryProvider)
|
|
.findMailboxByRole(t.accountId, 'archive');
|
|
if (!mounted || archive == null) return;
|
|
|
|
for (final id in t.emailIds) {
|
|
await repo.moveEmail(id, archive.path);
|
|
}
|
|
final action = UndoAction(
|
|
id: DateTime.now().toIso8601String(),
|
|
accountId: t.accountId,
|
|
type: UndoType.move,
|
|
emailIds: t.emailIds,
|
|
sourceMailboxPath: t.mailboxPath,
|
|
destinationMailboxPath: archive.path,
|
|
originalEmails: originalEmails,
|
|
);
|
|
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
|
return;
|
|
}
|
|
|
|
String? lastDestPath;
|
|
for (final id in t.emailIds) {
|
|
lastDestPath = await repo.deleteEmail(id);
|
|
}
|
|
final action = UndoAction(
|
|
id: DateTime.now().toIso8601String(),
|
|
accountId: t.accountId,
|
|
type: UndoType.delete,
|
|
emailIds: t.emailIds,
|
|
sourceMailboxPath: t.mailboxPath,
|
|
destinationMailboxPath: lastDestPath,
|
|
originalEmails: originalEmails,
|
|
);
|
|
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
|
}
|
|
|
|
Widget _swipeBackground({
|
|
required AlignmentGeometry alignment,
|
|
required Color color,
|
|
required IconData icon,
|
|
required String label,
|
|
}) {
|
|
return Container(
|
|
color: color,
|
|
alignment: alignment,
|
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, color: Colors.white),
|
|
const SizedBox(width: 8),
|
|
Text(label, style: const TextStyle(color: Colors.white)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|