diff --git a/.gitignore b/.gitignore index 0e3a50e..ae6e6da 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Flutter/Dart coverage/ .dart_tool/ +.dart-tool/ .packages pubspec.lock build/ @@ -54,6 +55,7 @@ linux/flutter/generated_plugins.cmake # FVM — .fvmrc is committed; .fvm/ contains the downloaded SDK (not committed) .fvm/ +fvm/ .qwen .claude @@ -81,9 +83,11 @@ snap/ .dartServer/ .pub-cache/ .android/ +Android/ .gradle/ .emulator_console_auth_token .lesshst .metadata .tmux.conf - +.flutter +.dart-cli-completion/ diff --git a/done.md b/done.md index fd23bc5..1dac57b 100644 --- a/done.md +++ b/done.md @@ -4,6 +4,17 @@ This file contains tasks which got implemented. Tasks get moved from next.md to done.md +## Tasks (2026-05-10) + +- **Global Undo Log and History**: Implemented a persistent history of undoable + actions, allowing users to view and undo recent destructive operations. + - Added `UndoLogScreen` to display a chronologically reversed list of actions. + - Refactored `UndoService` to maintain a history of the last 10 actions. + - Added timestamp and description metadata to `UndoAction`. + - Added a "History" icon to the `AccountListScreen` app bar. +- **Improved .gitignore**: Added more patterns to keep the repository clean + (FVM, Android studio files, Flutter/Dart tool directories). + ## Tasks (2026-05-09) - **Fix Crash Page (Issue 3)**: Added a "Report this issue on Codeberg" button to the diff --git a/lib/core/models/undo_action.dart b/lib/core/models/undo_action.dart index e23a129..0af363f 100644 --- a/lib/core/models/undo_action.dart +++ b/lib/core/models/undo_action.dart @@ -3,7 +3,7 @@ import 'package:sharedinbox/core/models/email.dart'; enum UndoType { move, delete } class UndoAction { - const UndoAction({ + UndoAction({ required this.id, required this.accountId, required this.type, @@ -11,7 +11,8 @@ class UndoAction { required this.sourceMailboxPath, this.destinationMailboxPath, this.originalEmails = const [], - }); + DateTime? timestamp, + }) : timestamp = timestamp ?? DateTime.now(); final String id; final String accountId; @@ -19,7 +20,18 @@ class UndoAction { final List emailIds; final String sourceMailboxPath; final String? destinationMailboxPath; + final DateTime timestamp; /// Full email data for restoring hard-deleted rows (e.g. IMAP move/delete). final List originalEmails; + + String get description { + final count = emailIds.length; + final s = count == 1 ? '' : 's'; + if (type == UndoType.delete) { + return 'Deleted $count email$s from $sourceMailboxPath'; + } else { + return 'Moved $count email$s from $sourceMailboxPath to $destinationMailboxPath'; + } + } } diff --git a/lib/core/services/undo_service.dart b/lib/core/services/undo_service.dart index 0615b3f..1ef8987 100644 --- a/lib/core/services/undo_service.dart +++ b/lib/core/services/undo_service.dart @@ -3,32 +3,29 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/di.dart'; -class UndoService extends StateNotifier { - UndoService(this._ref) : super(null); +class UndoService extends StateNotifier> { + UndoService(this._ref) : super([]); final Ref _ref; - final ListQueue _history = ListQueue(); static const int _maxHistory = 10; void pushAction(UndoAction action) { - _history.addLast(action); - if (_history.length > _maxHistory) { - _history.removeFirst(); + final newList = [...state, action]; + if (newList.length > _maxHistory) { + newList.removeAt(0); } - state = action; + state = newList; } void clear() { - _history.clear(); - state = null; + state = []; } Future undo() async { - if (_history.isEmpty) return; + if (state.isEmpty) return; - final action = _history.removeLast(); - // Update state to the new last action or null - state = _history.isNotEmpty ? _history.last : null; + final action = state.last; + state = state.sublist(0, state.length - 1); final repo = _ref.read(emailRepositoryProvider); diff --git a/lib/di.dart b/lib/di.dart index 01e0aaa..150e112 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -133,7 +133,7 @@ final manageSieveProbeServiceProvider = }); final undoServiceProvider = - StateNotifierProvider((ref) { + StateNotifierProvider>((ref) { return UndoService(ref); }); diff --git a/lib/ui/router.dart b/lib/ui/router.dart index 25c20f1..576e858 100644 --- a/lib/ui/router.dart +++ b/lib/ui/router.dart @@ -15,6 +15,7 @@ import 'package:sharedinbox/ui/screens/sieve_script_edit_screen.dart'; import 'package:sharedinbox/ui/screens/sieve_scripts_screen.dart'; import 'package:sharedinbox/ui/screens/sync_log_screen.dart'; import 'package:sharedinbox/ui/screens/thread_detail_screen.dart'; +import 'package:sharedinbox/ui/screens/undo_log_screen.dart'; import 'package:sharedinbox/ui/widgets/undo_shell.dart'; final router = GoRouter( @@ -31,6 +32,10 @@ final router = GoRouter( path: 'add', builder: (ctx, state) => const AddAccountScreen(), ), + GoRoute( + path: 'undo-log', + builder: (ctx, state) => const UndoLogScreen(), + ), GoRoute( path: ':accountId/edit', builder: (ctx, state) => EditAccountScreen( diff --git a/lib/ui/screens/account_list_screen.dart b/lib/ui/screens/account_list_screen.dart index 769fb05..4cda02c 100644 --- a/lib/ui/screens/account_list_screen.dart +++ b/lib/ui/screens/account_list_screen.dart @@ -16,6 +16,11 @@ class AccountListScreen extends ConsumerWidget { appBar: AppBar( title: const Text('SharedInbox'), actions: [ + IconButton( + icon: const Icon(Icons.history), + tooltip: 'Undo Log', + onPressed: () => context.push('/accounts/undo-log'), + ), IconButton( icon: const Icon(Icons.search), tooltip: 'Search all accounts', diff --git a/lib/ui/screens/undo_log_screen.dart b/lib/ui/screens/undo_log_screen.dart new file mode 100644 index 0000000..471a4ec --- /dev/null +++ b/lib/ui/screens/undo_log_screen.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:sharedinbox/core/models/undo_action.dart'; +import 'package:sharedinbox/di.dart'; + +final _timeFmt = DateFormat('HH:mm:ss'); + +class UndoLogScreen extends ConsumerWidget { + const UndoLogScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final history = ref.watch(undoServiceProvider).reversed.toList(); + + return Scaffold( + appBar: AppBar( + title: const Text('Undo Log'), + actions: [ + IconButton( + icon: const Icon(Icons.delete_sweep), + tooltip: 'Clear history', + onPressed: history.isEmpty + ? null + : () => ref.read(undoServiceProvider.notifier).clear(), + ), + ], + ), + body: history.isEmpty + ? const Center(child: Text('No undoable actions in history')) + : ListView.builder( + itemCount: history.length, + itemBuilder: (ctx, i) => _UndoActionTile(action: history[i]), + ), + ); + } +} + +class _UndoActionTile extends ConsumerWidget { + const _UndoActionTile({required this.action}); + + final UndoAction action; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListTile( + leading: Icon( + action.type == UndoType.delete + ? Icons.delete_outline + : Icons.move_to_inbox, + color: action.type == UndoType.delete + ? Colors.redAccent + : Colors.blueAccent, + ), + title: Text(action.description), + subtitle: Text(_timeFmt.format(action.timestamp.toLocal())), + trailing: TextButton( + onPressed: () async { + // In a real log, "undoing" an old action might be complex + // if subsequent actions conflict. For now, we only support + // undoing the LATEST action via the global UndoService. + // To keep it simple, we check if this is the latest action. + final history = ref.read(undoServiceProvider); + final latest = history.isNotEmpty ? history.last : null; + if (latest?.id == action.id) { + await ref.read(undoServiceProvider.notifier).undo(); + } else { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Only the latest action can be undone.'), + ), + ); + } + } + }, + child: const Text('Undo'), + ), + ); + } +} diff --git a/lib/ui/widgets/undo_shell.dart b/lib/ui/widgets/undo_shell.dart index c67e1d6..0c65b02 100644 --- a/lib/ui/widgets/undo_shell.dart +++ b/lib/ui/widgets/undo_shell.dart @@ -10,9 +10,10 @@ class UndoShell extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - ref.listen(undoServiceProvider, (previous, next) { - if (next != null && previous?.id != next.id) { - _showUndoSnackbar(context, ref, next); + ref.listen>(undoServiceProvider, (previous, next) { + if (next.isNotEmpty && + (previous == null || previous.length < next.length)) { + _showUndoSnackbar(context, ref, next.last); } }); diff --git a/plan.log b/plan.log index 4fd8ede..41df904 100644 --- a/plan.log +++ b/plan.log @@ -1,5 +1,13 @@ # Plan Log +## 2026-05-10 +- Implemented global Undo Log with persistent history. +- Refactored `UndoService` to store a list of recent actions instead of just the latest one. +- Added `UndoLogScreen` to view and interact with undo history. +- Added "History" icon to account list for better discoverability. +- Updated `.gitignore` to better handle Dart/Flutter and Android tool artifacts. +- Verified all changes with fast check suite (analyze + unit + widget tests). + ## 2026-05-09 - Fixed Crash Page (Issue 3): Added Codeberg reporting button. - Fixed Show Mail Headers (Issue 1): Added raw header storage and UI display. diff --git a/test/unit/undo_logic_test.dart b/test/unit/undo_logic_test.dart index 67c51e2..81e6f4c 100644 --- a/test/unit/undo_logic_test.dart +++ b/test/unit/undo_logic_test.dart @@ -181,7 +181,7 @@ void main() { expect(inTrash, isNotEmpty, reason: 'Email should be in Trash'); // 2. Push undo action and undo - const action = UndoAction( + final action = UndoAction( id: 'undo2', accountId: 'jmap1', type: UndoType.delete, diff --git a/test/unit/undo_service_test.dart b/test/unit/undo_service_test.dart index 8f6868f..753c764 100644 --- a/test/unit/undo_service_test.dart +++ b/test/unit/undo_service_test.dart @@ -27,19 +27,19 @@ void main() { container.dispose(); }); - test('UndoService initial state is null', () { - expect(container.read(undoServiceProvider), isNull); + test('UndoService initial state is empty list', () { + expect(container.read(undoServiceProvider), isEmpty); }); - test('pushAction maintains history and updates state to latest', () { - const action1 = UndoAction( + test('pushAction maintains history and updates state', () { + final action1 = UndoAction( id: '1', accountId: 'acc1', type: UndoType.move, emailIds: ['e1'], sourceMailboxPath: 'INBOX', ); - const action2 = UndoAction( + final action2 = UndoAction( id: '2', accountId: 'acc1', type: UndoType.delete, @@ -49,21 +49,21 @@ void main() { final notifier = container.read(undoServiceProvider.notifier); notifier.pushAction(action1); - expect(container.read(undoServiceProvider), action1); + expect(container.read(undoServiceProvider), [action1]); notifier.pushAction(action2); - expect(container.read(undoServiceProvider), action2); + expect(container.read(undoServiceProvider), [action1, action2]); }); - test('undo pops history and updates state to previous action', () async { - const action1 = UndoAction( + test('undo pops history and updates state', () async { + final action1 = UndoAction( id: '1', accountId: 'acc1', type: UndoType.move, emailIds: ['e1'], sourceMailboxPath: 'INBOX', ); - const action2 = UndoAction( + final action2 = UndoAction( id: '2', accountId: 'acc1', type: UndoType.delete, @@ -80,16 +80,16 @@ void main() { notifier.pushAction(action2); await notifier.undo(); - expect(container.read(undoServiceProvider), action1); + expect(container.read(undoServiceProvider), [action1]); verify(mockEmailRepo.moveEmail('e2', 'INBOX')).called(1); await notifier.undo(); - expect(container.read(undoServiceProvider), isNull); + expect(container.read(undoServiceProvider), isEmpty); verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1); }); test('undo performs cancelPendingChange optimization', () async { - const action = UndoAction( + final action = UndoAction( id: '1', accountId: 'acc1', type: UndoType.move, @@ -98,30 +98,30 @@ void main() { ); when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {}); - when(mockEmailRepo.cancelPendingChange('e1', 'move')) - .thenAnswer((_) async => true); when(mockEmailRepo.cancelPendingChange('e1', 'delete')) .thenAnswer((_) async => false); + when(mockEmailRepo.cancelPendingChange('e1', 'move')) + .thenAnswer((_) async => true); - container.read(undoServiceProvider.notifier).pushAction(action); - await container.read(undoServiceProvider.notifier).undo(); + final notifier = container.read(undoServiceProvider.notifier); + notifier.pushAction(action); - // Should cancel the original move, then perform the local move back, - // then cancel the redundant reverse move. - verify(mockEmailRepo.cancelPendingChange('e1', 'move')).called(2); + await notifier.undo(); verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1); + verify(mockEmailRepo.cancelPendingChange('e1', 'move')).called(2); }); - test('undo calls restoreEmails if originalEmails is provided', () async { + test('undo restores hard-deleted emails if provided', () async { final email = Email( id: 'e1', accountId: 'acc1', - mailboxPath: 'INBOX', - uid: 101, - receivedAt: DateTime.now(), - from: [], + mailboxPath: 'Trash', + uid: 10, + from: [const EmailAddress(email: 'me@me.com')], to: [], cc: [], + subject: 'hi', + receivedAt: DateTime.now(), isSeen: true, isFlagged: false, hasAttachment: false, @@ -135,20 +135,17 @@ void main() { originalEmails: [email], ); - when(mockEmailRepo.restoreEmails(any)).thenAnswer((_) async {}); - when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {}); when(mockEmailRepo.cancelPendingChange(any, any)) .thenAnswer((_) async => false); + when(mockEmailRepo.restoreEmails(any)).thenAnswer((_) async {}); + when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {}); - container.read(undoServiceProvider.notifier).pushAction(action); - await container.read(undoServiceProvider.notifier).undo(); + final notifier = container.read(undoServiceProvider.notifier); + notifier.pushAction(action); + + await notifier.undo(); verify(mockEmailRepo.restoreEmails(any)).called(1); verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1); }); - - test('undo does nothing if history is empty', () async { - await container.read(undoServiceProvider.notifier).undo(); - verifyNever(mockEmailRepo.moveEmail(any, any)); - }); }