export_accounts.dart 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. import 'package:flutter/material.dart';
  2. import 'package:provider/provider.dart';
  3. import '../state/app_state.dart';
  4. import 'package:pb_authenticator_totp/totp_item.dart';
  5. import 'package:pb_authenticator_totp/otpauth_migration.dart';
  6. import '../l10n/app_localizations.dart';
  7. import 'package:qr_flutter/qr_flutter.dart';
  8. class ExportAccountsPage extends StatefulWidget {
  9. const ExportAccountsPage({super.key});
  10. @override
  11. State<ExportAccountsPage> createState() => _ExportAccountsPageState();
  12. }
  13. class _ExportAccountsPageState extends State<ExportAccountsPage> {
  14. static const int maxAccountsPerBatch = 10; // Google Authenticator uses 10
  15. List<String> _qrUris = [];
  16. int _currentIndex = 0;
  17. List<bool> _selected = [];
  18. bool _qrGenerated = false;
  19. @override
  20. void initState() {
  21. super.initState();
  22. WidgetsBinding.instance.addPostFrameCallback((_) => _initSelection());
  23. }
  24. void _initSelection() {
  25. final state = Provider.of<AppState>(context, listen: false);
  26. final items = state.items ?? [];
  27. setState(() {
  28. _selected = List.generate(items.length, (_) => true);
  29. _qrUris = [];
  30. _qrGenerated = false;
  31. _currentIndex = 0;
  32. });
  33. }
  34. void _generateQrUris() {
  35. final state = Provider.of<AppState>(context, listen: false);
  36. final items = state.items ?? [];
  37. final selectedItems = <TotpItem>[];
  38. for (int i = 0; i < items.length; i++) {
  39. if (_selected[i]) {
  40. selectedItems.add(items[i].totp);
  41. }
  42. }
  43. if (selectedItems.isEmpty) {
  44. setState(() {
  45. _qrUris = [];
  46. _qrGenerated = true;
  47. });
  48. return;
  49. }
  50. final otpAuths = selectedItems.map((item) => _toOtpAuthUri(item)).toList();
  51. final qrUris = <String>[];
  52. for (int i = 0; i < otpAuths.length; i += maxAccountsPerBatch) {
  53. final batch = otpAuths.sublist(i, (i + maxAccountsPerBatch > otpAuths.length) ? otpAuths.length : i + maxAccountsPerBatch);
  54. final uri = OtpAuthMigration().encode(batch, batchSize: otpAuths.length, batchIndex: (i ~/ maxAccountsPerBatch), batchId: 1);
  55. qrUris.add(uri);
  56. }
  57. setState(() {
  58. _qrUris = qrUris;
  59. _qrGenerated = true;
  60. _currentIndex = 0;
  61. });
  62. if (qrUris.isNotEmpty) {
  63. _showQrDialog(qrUris);
  64. }
  65. }
  66. void _showQrDialog(List<String> qrUris) {
  67. int dialogIndex = 0;
  68. showDialog(
  69. context: context,
  70. builder: (context) {
  71. return StatefulBuilder(
  72. builder: (context, setState) {
  73. return AlertDialog(
  74. contentPadding: const EdgeInsets.all(16),
  75. content: SizedBox(
  76. width: 360,
  77. child: Column(
  78. mainAxisSize: MainAxisSize.min,
  79. children: [
  80. QrImageView(
  81. data: qrUris[dialogIndex],
  82. version: QrVersions.auto,
  83. size: 320,
  84. backgroundColor: Colors.white,
  85. errorStateBuilder: (cxt, err) => Center(child: Text('QR error', style: TextStyle(color: Colors.red))),
  86. ),
  87. if (qrUris.length > 1)
  88. Padding(
  89. padding: const EdgeInsets.only(top: 16),
  90. child: Row(
  91. mainAxisAlignment: MainAxisAlignment.center,
  92. children: [
  93. IconButton(
  94. icon: const Icon(Icons.arrow_back),
  95. onPressed: dialogIndex > 0
  96. ? () => setState(() => dialogIndex--)
  97. : null,
  98. ),
  99. Text('${dialogIndex + 1} / ${qrUris.length}'),
  100. IconButton(
  101. icon: const Icon(Icons.arrow_forward),
  102. onPressed: dialogIndex < qrUris.length - 1
  103. ? () => setState(() => dialogIndex++)
  104. : null,
  105. ),
  106. ],
  107. ),
  108. ),
  109. ],
  110. ),
  111. ),
  112. actions: [
  113. TextButton(
  114. onPressed: () => Navigator.of(context).pop(),
  115. child: const Text('Close'),
  116. ),
  117. ],
  118. );
  119. },
  120. );
  121. },
  122. );
  123. }
  124. String _toOtpAuthUri(TotpItem item) {
  125. final algorithm = item.algorithm.name.toUpperCase();
  126. return 'otpauth://totp/${Uri.encodeComponent(item.issuer)}:${Uri.encodeComponent(item.accountName)}?secret=${item.secret}&issuer=${Uri.encodeComponent(item.issuer)}&algorithm=$algorithm&digits=${item.digits}&period=${item.period}';
  127. }
  128. @override
  129. Widget build(BuildContext context) {
  130. final state = Provider.of<AppState>(context);
  131. final items = state.items ?? [];
  132. final loc = AppLocalizations.of(context);
  133. return Scaffold(
  134. appBar: AppBar(title: Text(loc?.exportAccounts ?? 'Export accounts')),
  135. body: items.isEmpty
  136. ? Center(child: Text(loc?.noAccounts ?? 'No accounts to export.'))
  137. : Padding(
  138. padding: const EdgeInsets.all(16.0),
  139. child: Column(
  140. children: [
  141. Icon(Icons.qr_code, size: 48, color: Colors.blue),
  142. const SizedBox(height: 8),
  143. Text(
  144. loc?.exportDescription ?? 'Select which accounts to export and generate a QR code to transfer them to another device.',
  145. textAlign: TextAlign.center,
  146. style: Theme.of(context).textTheme.bodyMedium,
  147. ),
  148. const SizedBox(height: 16),
  149. Expanded(
  150. child: ListView.builder(
  151. itemCount: items.length,
  152. itemBuilder: (context, i) {
  153. final item = items[i];
  154. return CheckboxListTile(
  155. value: _selected[i],
  156. onChanged: (val) {
  157. setState(() {
  158. _selected[i] = val ?? false;
  159. });
  160. },
  161. title: Text('${item.totp.issuer}: ${item.totp.accountName}'),
  162. );
  163. },
  164. ),
  165. ),
  166. ElevatedButton.icon(
  167. icon: const Icon(Icons.qr_code),
  168. label: Text(loc?.generateQr ?? 'Generate QR'),
  169. onPressed: _selected.any((s) => s) ? _generateQrUris : null,
  170. ),
  171. const SizedBox(height: 16),
  172. if (_qrGenerated && _qrUris.isEmpty)
  173. Text(loc?.noAccountsSelected ?? 'No accounts selected.'),
  174. ],
  175. ),
  176. ),
  177. );
  178. }
  179. }
  180. class BarcodePainterWidget extends StatelessWidget {
  181. final String data;
  182. const BarcodePainterWidget({super.key, required this.data});
  183. @override
  184. Widget build(BuildContext context) {
  185. return QrImageView(
  186. data: data,
  187. version: QrVersions.auto,
  188. size: 240,
  189. backgroundColor: Colors.white,
  190. errorStateBuilder: (cxt, err) => Center(child: Text('QR error', style: TextStyle(color: Colors.red))),
  191. );
  192. }
  193. }