|
|
@@ -3,8 +3,8 @@ import 'package:provider/provider.dart';
|
|
|
import '../state/app_state.dart';
|
|
|
import 'package:pb_authenticator_totp/totp_item.dart';
|
|
|
import 'package:pb_authenticator_totp/otpauth_migration.dart';
|
|
|
-import 'package:barcode_scan2/barcode_scan2.dart';
|
|
|
import '../l10n/app_localizations.dart';
|
|
|
+import 'package:qr_flutter/qr_flutter.dart';
|
|
|
|
|
|
class ExportAccountsPage extends StatefulWidget {
|
|
|
const ExportAccountsPage({super.key});
|
|
|
@@ -17,24 +17,116 @@ class _ExportAccountsPageState extends State<ExportAccountsPage> {
|
|
|
static const int maxAccountsPerBatch = 10; // Google Authenticator uses 10
|
|
|
List<String> _qrUris = [];
|
|
|
int _currentIndex = 0;
|
|
|
+ List<bool> _selected = [];
|
|
|
+ bool _qrGenerated = false;
|
|
|
|
|
|
@override
|
|
|
void initState() {
|
|
|
super.initState();
|
|
|
- WidgetsBinding.instance.addPostFrameCallback((_) => _generateQrUris());
|
|
|
+ WidgetsBinding.instance.addPostFrameCallback((_) => _initSelection());
|
|
|
+ }
|
|
|
+
|
|
|
+ void _initSelection() {
|
|
|
+ final state = Provider.of<AppState>(context, listen: false);
|
|
|
+ final items = state.items ?? [];
|
|
|
+ setState(() {
|
|
|
+ _selected = List.generate(items.length, (_) => true);
|
|
|
+ _qrUris = [];
|
|
|
+ _qrGenerated = false;
|
|
|
+ _currentIndex = 0;
|
|
|
+ });
|
|
|
}
|
|
|
|
|
|
void _generateQrUris() {
|
|
|
final state = Provider.of<AppState>(context, listen: false);
|
|
|
final items = state.items ?? [];
|
|
|
- final otpAuths = items.map((item) => _toOtpAuthUri(item.totp)).toList();
|
|
|
- _qrUris = [];
|
|
|
+ final selectedItems = <TotpItem>[];
|
|
|
+ for (int i = 0; i < items.length; i++) {
|
|
|
+ if (_selected[i]) {
|
|
|
+ selectedItems.add(items[i].totp);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (selectedItems.isEmpty) {
|
|
|
+ setState(() {
|
|
|
+ _qrUris = [];
|
|
|
+ _qrGenerated = true;
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ final otpAuths = selectedItems.map((item) => _toOtpAuthUri(item)).toList();
|
|
|
+ final qrUris = <String>[];
|
|
|
for (int i = 0; i < otpAuths.length; i += maxAccountsPerBatch) {
|
|
|
final batch = otpAuths.sublist(i, (i + maxAccountsPerBatch > otpAuths.length) ? otpAuths.length : i + maxAccountsPerBatch);
|
|
|
final uri = OtpAuthMigration().encode(batch, batchSize: otpAuths.length, batchIndex: (i ~/ maxAccountsPerBatch), batchId: 1);
|
|
|
- _qrUris.add(uri);
|
|
|
+ qrUris.add(uri);
|
|
|
+ }
|
|
|
+ setState(() {
|
|
|
+ _qrUris = qrUris;
|
|
|
+ _qrGenerated = true;
|
|
|
+ _currentIndex = 0;
|
|
|
+ });
|
|
|
+ if (qrUris.isNotEmpty) {
|
|
|
+ _showQrDialog(qrUris);
|
|
|
}
|
|
|
- setState(() {});
|
|
|
+ }
|
|
|
+
|
|
|
+ void _showQrDialog(List<String> qrUris) {
|
|
|
+ int dialogIndex = 0;
|
|
|
+ showDialog(
|
|
|
+ context: context,
|
|
|
+ builder: (context) {
|
|
|
+ return StatefulBuilder(
|
|
|
+ builder: (context, setState) {
|
|
|
+ return AlertDialog(
|
|
|
+ contentPadding: const EdgeInsets.all(16),
|
|
|
+ content: SizedBox(
|
|
|
+ width: 360,
|
|
|
+ child: Column(
|
|
|
+ mainAxisSize: MainAxisSize.min,
|
|
|
+ children: [
|
|
|
+ QrImageView(
|
|
|
+ data: qrUris[dialogIndex],
|
|
|
+ version: QrVersions.auto,
|
|
|
+ size: 320,
|
|
|
+ backgroundColor: Colors.white,
|
|
|
+ errorStateBuilder: (cxt, err) => Center(child: Text('QR error', style: TextStyle(color: Colors.red))),
|
|
|
+ ),
|
|
|
+ if (qrUris.length > 1)
|
|
|
+ Padding(
|
|
|
+ padding: const EdgeInsets.only(top: 16),
|
|
|
+ child: Row(
|
|
|
+ mainAxisAlignment: MainAxisAlignment.center,
|
|
|
+ children: [
|
|
|
+ IconButton(
|
|
|
+ icon: const Icon(Icons.arrow_back),
|
|
|
+ onPressed: dialogIndex > 0
|
|
|
+ ? () => setState(() => dialogIndex--)
|
|
|
+ : null,
|
|
|
+ ),
|
|
|
+ Text('${dialogIndex + 1} / ${qrUris.length}'),
|
|
|
+ IconButton(
|
|
|
+ icon: const Icon(Icons.arrow_forward),
|
|
|
+ onPressed: dialogIndex < qrUris.length - 1
|
|
|
+ ? () => setState(() => dialogIndex++)
|
|
|
+ : null,
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ actions: [
|
|
|
+ TextButton(
|
|
|
+ onPressed: () => Navigator.of(context).pop(),
|
|
|
+ child: const Text('Close'),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ );
|
|
|
+ },
|
|
|
+ );
|
|
|
+ },
|
|
|
+ );
|
|
|
}
|
|
|
|
|
|
String _toOtpAuthUri(TotpItem item) {
|
|
|
@@ -44,61 +136,53 @@ class _ExportAccountsPageState extends State<ExportAccountsPage> {
|
|
|
|
|
|
@override
|
|
|
Widget build(BuildContext context) {
|
|
|
- if (_qrUris.isEmpty) {
|
|
|
- final state = Provider.of<AppState>(context, listen: false);
|
|
|
- final items = state.items ?? [];
|
|
|
- if (items.isEmpty) {
|
|
|
- return Scaffold(
|
|
|
- appBar: AppBar(title: Text(AppLocalizations.of(context)?.exportAccounts ?? 'Export accounts')),
|
|
|
- body: Center(child: Text(AppLocalizations.of(context)?.noAccounts ?? 'No accounts to export.')),
|
|
|
- );
|
|
|
- }
|
|
|
- // Still loading QR codes
|
|
|
- return Scaffold(
|
|
|
- appBar: AppBar(title: Text(AppLocalizations.of(context)?.exportAccounts ?? 'Export accounts')),
|
|
|
- body: const Center(child: CircularProgressIndicator()),
|
|
|
- );
|
|
|
- }
|
|
|
+ final state = Provider.of<AppState>(context);
|
|
|
+ final items = state.items ?? [];
|
|
|
+ final loc = AppLocalizations.of(context);
|
|
|
return Scaffold(
|
|
|
- appBar: AppBar(title: Text(AppLocalizations.of(context)?.exportAccounts ?? 'Export accounts')),
|
|
|
- body: Column(
|
|
|
- mainAxisAlignment: MainAxisAlignment.center,
|
|
|
- children: [
|
|
|
- Text(
|
|
|
- AppLocalizations.of(context)?.exportAccounts ?? 'Export accounts',
|
|
|
- style: Theme.of(context).textTheme.titleLarge,
|
|
|
- ),
|
|
|
- const SizedBox(height: 16),
|
|
|
- Expanded(
|
|
|
- child: Center(
|
|
|
- child: BarcodePainterWidget(data: _qrUris[_currentIndex]),
|
|
|
- ),
|
|
|
- ),
|
|
|
- if (_qrUris.length > 1)
|
|
|
- Padding(
|
|
|
- padding: const EdgeInsets.symmetric(vertical: 8.0),
|
|
|
- child: Row(
|
|
|
- mainAxisAlignment: MainAxisAlignment.center,
|
|
|
+ appBar: AppBar(title: Text(loc?.exportAccounts ?? 'Export accounts')),
|
|
|
+ body: items.isEmpty
|
|
|
+ ? Center(child: Text(loc?.noAccounts ?? 'No accounts to export.'))
|
|
|
+ : Padding(
|
|
|
+ padding: const EdgeInsets.all(16.0),
|
|
|
+ child: Column(
|
|
|
children: [
|
|
|
- IconButton(
|
|
|
- icon: const Icon(Icons.arrow_back),
|
|
|
- onPressed: _currentIndex > 0
|
|
|
- ? () => setState(() => _currentIndex--)
|
|
|
- : null,
|
|
|
+ Icon(Icons.qr_code, size: 48, color: Colors.blue),
|
|
|
+ const SizedBox(height: 8),
|
|
|
+ Text(
|
|
|
+ loc?.exportDescription ?? 'Select which accounts to export and generate a QR code to transfer them to another device.',
|
|
|
+ textAlign: TextAlign.center,
|
|
|
+ style: Theme.of(context).textTheme.bodyMedium,
|
|
|
+ ),
|
|
|
+ const SizedBox(height: 16),
|
|
|
+ Expanded(
|
|
|
+ child: ListView.builder(
|
|
|
+ itemCount: items.length,
|
|
|
+ itemBuilder: (context, i) {
|
|
|
+ final item = items[i];
|
|
|
+ return CheckboxListTile(
|
|
|
+ value: _selected[i],
|
|
|
+ onChanged: (val) {
|
|
|
+ setState(() {
|
|
|
+ _selected[i] = val ?? false;
|
|
|
+ });
|
|
|
+ },
|
|
|
+ title: Text('${item.totp.issuer}: ${item.totp.accountName}'),
|
|
|
+ );
|
|
|
+ },
|
|
|
+ ),
|
|
|
),
|
|
|
- Text('${_currentIndex + 1} / ${_qrUris.length}'),
|
|
|
- IconButton(
|
|
|
- icon: const Icon(Icons.arrow_forward),
|
|
|
- onPressed: _currentIndex < _qrUris.length - 1
|
|
|
- ? () => setState(() => _currentIndex++)
|
|
|
- : null,
|
|
|
+ ElevatedButton.icon(
|
|
|
+ icon: const Icon(Icons.qr_code),
|
|
|
+ label: Text(loc?.generateQr ?? 'Generate QR'),
|
|
|
+ onPressed: _selected.any((s) => s) ? _generateQrUris : null,
|
|
|
),
|
|
|
+ const SizedBox(height: 16),
|
|
|
+ if (_qrGenerated && _qrUris.isEmpty)
|
|
|
+ Text(loc?.noAccountsSelected ?? 'No accounts selected.'),
|
|
|
],
|
|
|
),
|
|
|
),
|
|
|
- const SizedBox(height: 16),
|
|
|
- ],
|
|
|
- ),
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
@@ -109,17 +193,12 @@ class BarcodePainterWidget extends StatelessWidget {
|
|
|
|
|
|
@override
|
|
|
Widget build(BuildContext context) {
|
|
|
- // barcode_scan2 does not provide a direct widget, so we use a placeholder for now.
|
|
|
- // You should replace this with a QR code widget compatible with your requirements.
|
|
|
- return Container(
|
|
|
- color: Colors.white,
|
|
|
- width: 240,
|
|
|
- height: 240,
|
|
|
- alignment: Alignment.center,
|
|
|
- child: Text(
|
|
|
- 'QR code here',
|
|
|
- style: TextStyle(color: Colors.black),
|
|
|
- ),
|
|
|
+ return QrImageView(
|
|
|
+ data: data,
|
|
|
+ version: QrVersions.auto,
|
|
|
+ size: 240,
|
|
|
+ backgroundColor: Colors.white,
|
|
|
+ errorStateBuilder: (cxt, err) => Center(child: Text('QR error', style: TextStyle(color: Colors.red))),
|
|
|
);
|
|
|
}
|
|
|
-}
|
|
|
+}
|