feat(accounts): share account settings via QR code / JSON export (#66)

Add Export account screen (QR code + copy-to-clipboard) and Import
account screen (paste JSON code) so users can transfer IMAP/JMAP
account configuration to another device without re-entering every field.

- Account list popup: "Export account" opens a QR code with a password
  warning and a copy-code button.
- Add Account screen: "Import account" button opens the import flow
  where pasting the exported JSON pre-fills the account and one tap
  saves it with a fresh generated ID.
- New routes: /accounts/:id/export and /accounts/import.
- Widget tests cover export display, import parsing, validation,
  and the happy-path save-and-navigate flow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas SharedInbox
2026-05-15 16:53:36 +02:00
co-authored by Claude Sonnet 4.6
parent 122358c9a2
commit 99df6f5fd0
11 changed files with 533 additions and 1 deletions
+12
View File
@@ -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) =>
+129
View File
@@ -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<AccountExportScreen> createState() =>
_AccountExportScreenState();
}
class _AccountExportScreenState extends ConsumerState<AccountExportScreen> {
bool _loading = true;
String? _exportCode;
String? _error;
@override
void initState() {
super.initState();
unawaited(_load());
}
Future<void> _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),
),
],
),
);
}
}
+172
View File
@@ -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<AccountImportScreen> createState() =>
_AccountImportScreenState();
}
class _AccountImportScreenState extends ConsumerState<AccountImportScreen> {
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<String, dynamic>;
if ((json['v'] as int?) != 1) {
throw const FormatException('Unknown version');
}
final account = Account.fromJson(
json['account'] as Map<String, dynamic>,
);
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<void> _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'),
),
],
),
),
);
}
}
+8 -1
View File
@@ -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<bool>(
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].
///
+7
View File
@@ -295,6 +295,13 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
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'),
),
],
),
),
+3
View File
@@ -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
+2
View File
@@ -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',
+162
View File
@@ -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<FilledButton>(
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<FilledButton>(
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);
});
});
}
+17
View File
@@ -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 {
+9
View File
@@ -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 {
+12
View File
@@ -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) =>