diff --git a/lib/ui/router.dart b/lib/ui/router.dart index 69d7eb1..89344a0 100644 --- a/lib/ui/router.dart +++ b/lib/ui/router.dart @@ -2,6 +2,8 @@ import 'package:go_router/go_router.dart'; import 'package:sharedinbox/core/models/sieve_script.dart'; +import 'package:sharedinbox/ui/screens/account_export_screen.dart'; +import 'package:sharedinbox/ui/screens/account_import_screen.dart'; import 'package:sharedinbox/ui/screens/account_list_screen.dart'; import 'package:sharedinbox/ui/screens/add_account_screen.dart'; import 'package:sharedinbox/ui/screens/address_emails_screen.dart'; @@ -33,6 +35,10 @@ final router = GoRouter( path: 'add', builder: (ctx, state) => const AddAccountScreen(), ), + GoRoute( + path: 'import', + builder: (ctx, state) => const AccountImportScreen(), + ), GoRoute( path: 'undo-log', builder: (ctx, state) => const UndoLogScreen(), @@ -47,6 +53,12 @@ final router = GoRouter( accountId: state.pathParameters['accountId']!, ), ), + GoRoute( + path: ':accountId/export', + builder: (ctx, state) => AccountExportScreen( + accountId: state.pathParameters['accountId']!, + ), + ), GoRoute( path: ':accountId/sync-log', builder: (ctx, state) => diff --git a/lib/ui/screens/account_export_screen.dart b/lib/ui/screens/account_export_screen.dart new file mode 100644 index 0000000..0fde9f9 --- /dev/null +++ b/lib/ui/screens/account_export_screen.dart @@ -0,0 +1,129 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +import 'package:sharedinbox/di.dart'; + +class AccountExportScreen extends ConsumerStatefulWidget { + const AccountExportScreen({super.key, required this.accountId}); + + final String accountId; + + @override + ConsumerState createState() => + _AccountExportScreenState(); +} + +class _AccountExportScreenState extends ConsumerState { + bool _loading = true; + String? _exportCode; + String? _error; + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + Future _load() async { + try { + final repo = ref.read(accountRepositoryProvider); + final account = await repo.getAccount(widget.accountId); + if (account == null) { + setState(() { + _error = 'Account not found'; + _loading = false; + }); + return; + } + final password = await repo.getPassword(widget.accountId); + final payload = jsonEncode({ + 'v': 1, + 'account': account.toJson(), + 'password': password, + }); + setState(() { + _exportCode = payload; + _loading = false; + }); + } catch (e) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Export account')), + body: _buildBody(context), + ); + } + + Widget _buildBody(BuildContext context) { + if (_loading) return const Center(child: CircularProgressIndicator()); + if (_error != null) { + return Center(child: Text('Error: $_error')); + } + + final theme = Theme.of(context); + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Card( + color: theme.colorScheme.errorContainer, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Icon(Icons.warning_amber, color: theme.colorScheme.error), + const SizedBox(width: 8), + const Expanded( + child: Text( + 'This code contains your password. Keep it private.', + ), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + Center( + child: QrImageView( + key: const Key('accountQrCode'), + data: _exportCode!, + size: 260, + ), + ), + const SizedBox(height: 24), + OutlinedButton.icon( + key: const Key('copyCodeButton'), + icon: const Icon(Icons.copy), + label: const Text('Copy code'), + onPressed: () { + unawaited(Clipboard.setData(ClipboardData(text: _exportCode!))); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Code copied to clipboard')), + ); + }, + ), + const SizedBox(height: 8), + const Text( + 'Scan the QR code on your other device, or tap "Copy code" and ' + 'paste it into the "Import account" screen.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12), + ), + ], + ), + ); + } +} diff --git a/lib/ui/screens/account_import_screen.dart b/lib/ui/screens/account_import_screen.dart new file mode 100644 index 0000000..a6b0a1b --- /dev/null +++ b/lib/ui/screens/account_import_screen.dart @@ -0,0 +1,172 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:sharedinbox/core/models/account.dart'; +import 'package:sharedinbox/di.dart'; + +class AccountImportScreen extends ConsumerStatefulWidget { + const AccountImportScreen({super.key}); + + @override + ConsumerState createState() => + _AccountImportScreenState(); +} + +class _AccountImportScreenState extends ConsumerState { + final _ctrl = TextEditingController(); + Account? _parsed; + String? _parsedPassword; + String? _parseError; + bool _saving = false; + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + void _onTextChanged(String value) { + final text = value.trim(); + if (text.isEmpty) { + setState(() { + _parsed = null; + _parsedPassword = null; + _parseError = null; + }); + return; + } + try { + final json = jsonDecode(text) as Map; + if ((json['v'] as int?) != 1) { + throw const FormatException('Unknown version'); + } + final account = Account.fromJson( + json['account'] as Map, + ); + final password = json['password'] as String; + setState(() { + _parsed = Account( + id: DateTime.now().millisecondsSinceEpoch.toString(), + displayName: account.displayName, + email: account.email, + username: account.username, + type: account.type, + imapHost: account.imapHost, + imapPort: account.imapPort, + imapSsl: account.imapSsl, + smtpHost: account.smtpHost, + smtpPort: account.smtpPort, + smtpSsl: account.smtpSsl, + manageSieveHost: account.manageSieveHost, + manageSievePort: account.manageSievePort, + manageSieveSsl: account.manageSieveSsl, + jmapUrl: account.jmapUrl, + ); + _parsedPassword = password; + _parseError = null; + }); + } catch (_) { + setState(() { + _parsed = null; + _parsedPassword = null; + _parseError = + 'Invalid code — paste the full text from "Export account"'; + }); + } + } + + Future _import() async { + if (_parsed == null || _parsedPassword == null) return; + setState(() => _saving = true); + try { + await ref + .read(accountRepositoryProvider) + .addAccount(_parsed!, _parsedPassword!); + if (mounted) context.pop(); + } catch (e) { + if (mounted) { + setState(() => _saving = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Import failed: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar(title: const Text('Import account')), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'On your other device, open the account menu and tap ' + '"Export account". Then copy the code and paste it below.', + ), + const SizedBox(height: 16), + TextField( + key: const Key('importCodeField'), + controller: _ctrl, + maxLines: 6, + onChanged: _onTextChanged, + decoration: const InputDecoration( + labelText: 'Account code', + border: OutlineInputBorder(), + hintText: 'Paste code here', + ), + ), + if (_parseError != null) ...[ + const SizedBox(height: 8), + Text( + _parseError!, + style: TextStyle(color: theme.colorScheme.error), + ), + ], + if (_parsed != null) ...[ + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ready to import:', + style: theme.textTheme.titleSmall, + ), + const SizedBox(height: 4), + Text(_parsed!.displayName), + Text(_parsed!.email), + Text( + _parsed!.type == AccountType.jmap ? 'JMAP' : 'IMAP', + ), + ], + ), + ), + ), + ], + const SizedBox(height: 16), + FilledButton( + key: const Key('importButton'), + onPressed: (_parsed != null && !_saving) ? _import : null, + child: _saving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Import'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/ui/screens/account_list_screen.dart b/lib/ui/screens/account_list_screen.dart index 75c859b..7a9da08 100644 --- a/lib/ui/screens/account_list_screen.dart +++ b/lib/ui/screens/account_list_screen.dart @@ -163,6 +163,10 @@ class _AccountTile extends ConsumerWidget { value: _AccountAction.emailFilters, child: Text('Email filters'), ), + const PopupMenuItem( + value: _AccountAction.export, + child: Text('Export account'), + ), const PopupMenuDivider(), const PopupMenuItem( value: _AccountAction.delete, @@ -202,6 +206,9 @@ class _AccountTile extends ConsumerWidget { case _AccountAction.emailFilters: await context.push('/accounts/${account.id}/sieve'); break; + case _AccountAction.export: + await context.push('/accounts/${account.id}/export'); + break; case _AccountAction.delete: final confirmed = await showDialog( context: context, @@ -337,7 +344,7 @@ class _Step extends StatelessWidget { } } -enum _AccountAction { syncLog, verifySync, edit, emailFilters, delete } +enum _AccountAction { syncLog, verifySync, edit, emailFilters, export, delete } /// Whether to surface the "Email filters" (Sieve) entry for [account]. /// diff --git a/lib/ui/screens/add_account_screen.dart b/lib/ui/screens/add_account_screen.dart index 71918ea..89d7f93 100644 --- a/lib/ui/screens/add_account_screen.dart +++ b/lib/ui/screens/add_account_screen.dart @@ -295,6 +295,13 @@ class _AddAccountScreenState extends ConsumerState { onPressed: _detectAccount, child: const Text('Continue'), ), + const SizedBox(height: 8), + OutlinedButton.icon( + key: const Key('importAccountButton'), + icon: const Icon(Icons.qr_code_scanner), + label: const Text('Import account'), + onPressed: () => context.push('/accounts/import'), + ), ], ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 53d5a86..75eafe7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,9 @@ dependencies: open_filex: ^4.6.0 mime: ^2.0.0 + # QR code generation for account sharing + qr_flutter: ^4.1.0 + # HTML rendering for email bodies webview_flutter: ^4.0.0 url_launcher: ^6.3.2 diff --git a/scripts/check_coverage.dart b/scripts/check_coverage.dart index 82c8899..26b7b03 100644 --- a/scripts/check_coverage.dart +++ b/scripts/check_coverage.dart @@ -32,6 +32,8 @@ const _excluded = { 'lib/di.dart', 'lib/main.dart', 'lib/ui/router.dart', + 'lib/ui/screens/account_export_screen.dart', + 'lib/ui/screens/account_import_screen.dart', 'lib/ui/screens/account_list_screen.dart', 'lib/ui/screens/add_account_screen.dart', 'lib/ui/screens/address_emails_screen.dart', diff --git a/test/widget/account_export_screen_test.dart b/test/widget/account_export_screen_test.dart new file mode 100644 index 0000000..cc8ae67 --- /dev/null +++ b/test/widget/account_export_screen_test.dart @@ -0,0 +1,162 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sharedinbox/core/models/account.dart'; + +import 'helpers.dart'; + +void main() { + group('AccountExportScreen', () { + const account = Account( + id: 'acc-1', + displayName: 'Alice', + email: 'alice@example.com', + imapHost: 'imap.example.com', + smtpHost: 'smtp.example.com', + ); + + testWidgets('shows QR code and copy button after loading', (tester) async { + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/acc-1/export', + overrides: baseOverrides(accounts: [account]), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('accountQrCode')), findsOneWidget); + expect(find.byKey(const Key('copyCodeButton')), findsOneWidget); + }); + + testWidgets('shows password warning', (tester) async { + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/acc-1/export', + overrides: baseOverrides(accounts: [account]), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.textContaining('password'), + findsAtLeastNWidgets(1), + ); + }); + }); + + group('AccountImportScreen', () { + testWidgets('shows instruction text and disabled import button', ( + tester, + ) async { + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/import', + overrides: baseOverrides(), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Import account'), findsOneWidget); + expect(find.byKey(const Key('importCodeField')), findsOneWidget); + + final importBtn = tester.widget( + find.byKey(const Key('importButton')), + ); + expect(importBtn.onPressed, isNull); + }); + + testWidgets('invalid JSON shows error message', (tester) async { + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/import', + overrides: baseOverrides(), + ), + ); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('importCodeField')), + 'not valid json', + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('Invalid code'), findsOneWidget); + }); + + testWidgets('valid code shows account preview and enables import', ( + tester, + ) async { + const account = Account( + id: 'acc-99', + displayName: 'Bob', + email: 'bob@example.com', + imapHost: 'imap.example.com', + smtpHost: 'smtp.example.com', + ); + final code = jsonEncode({ + 'v': 1, + 'account': account.toJson(), + 'password': 'secret', + }); + + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/import', + overrides: baseOverrides(), + ), + ); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('importCodeField')), + code, + ); + await tester.pumpAndSettle(); + + expect(find.text('Bob'), findsOneWidget); + expect(find.text('bob@example.com'), findsOneWidget); + + final importBtn = tester.widget( + find.byKey(const Key('importButton')), + ); + expect(importBtn.onPressed, isNotNull); + }); + + testWidgets('successful import navigates back to accounts list', ( + tester, + ) async { + const account = Account( + id: 'acc-99', + displayName: 'Bob', + email: 'bob@example.com', + imapHost: 'imap.example.com', + smtpHost: 'smtp.example.com', + ); + final code = jsonEncode({ + 'v': 1, + 'account': account.toJson(), + 'password': 'secret', + }); + + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/import', + overrides: baseOverrides(), + ), + ); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('importCodeField')), + code, + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('importButton'))); + await tester.pumpAndSettle(); + + expect(find.text('SharedInbox'), findsOneWidget); + }); + }); +} diff --git a/test/widget/account_list_screen_test.dart b/test/widget/account_list_screen_test.dart index 3c19555..d1e9f22 100644 --- a/test/widget/account_list_screen_test.dart +++ b/test/widget/account_list_screen_test.dart @@ -136,6 +136,23 @@ void main() { expect(find.text('Add account'), findsOneWidget); }); + testWidgets('account popup menu contains Export account item', ( + tester, + ) async { + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts', + overrides: baseOverrides(accounts: [kTestAccount]), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + + expect(find.text('Export account'), findsOneWidget); + }); + testWidgets('AppBar does not overflow at minimum supported window size', ( tester, ) async { diff --git a/test/widget/add_account_screen_test.dart b/test/widget/add_account_screen_test.dart index 255626d..b418ff6 100644 --- a/test/widget/add_account_screen_test.dart +++ b/test/widget/add_account_screen_test.dart @@ -7,6 +7,15 @@ import 'helpers.dart'; void main() { group('AddAccountScreen', () { + testWidgets('step 1: shows Import account button', (tester) async { + await tester.pumpWidget( + buildApp(initialLocation: '/accounts/add', overrides: baseOverrides()), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('importAccountButton')), findsOneWidget); + }); + testWidgets('step 1: shows email field and Continue button', ( tester, ) async { diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index e9236bc..1223f55 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -22,6 +22,8 @@ import 'package:sharedinbox/core/services/account_discovery_service.dart'; import 'package:sharedinbox/core/services/connection_test_service.dart'; import 'package:sharedinbox/core/services/managesieve_probe_service.dart'; import 'package:sharedinbox/di.dart'; +import 'package:sharedinbox/ui/screens/account_export_screen.dart'; +import 'package:sharedinbox/ui/screens/account_import_screen.dart'; import 'package:sharedinbox/ui/screens/account_list_screen.dart'; import 'package:sharedinbox/ui/screens/add_account_screen.dart'; import 'package:sharedinbox/ui/screens/address_emails_screen.dart'; @@ -369,12 +371,22 @@ Widget buildApp({ path: 'add', builder: (ctx, state) => const AddAccountScreen(), ), + GoRoute( + path: 'import', + builder: (ctx, state) => const AccountImportScreen(), + ), GoRoute( path: ':accountId/edit', builder: (ctx, state) => EditAccountScreen( accountId: state.pathParameters['accountId']!, ), ), + GoRoute( + path: ':accountId/export', + builder: (ctx, state) => AccountExportScreen( + accountId: state.pathParameters['accountId']!, + ), + ), GoRoute( path: ':accountId/search', builder: (ctx, state) =>