export_accounts.dart 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  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. import 'package:pb_authenticator_state/state.dart';
  9. class ExportAccountsPage extends StatefulWidget {
  10. const ExportAccountsPage({super.key});
  11. @override
  12. State<ExportAccountsPage> createState() => _ExportAccountsPageState();
  13. }
  14. class _ExportAccountsPageState extends State<ExportAccountsPage> {
  15. static const int maxAccountsPerBatch = 10; // Google Authenticator uses 10
  16. List<String> _qrUris = [];
  17. int _currentIndex = 0;
  18. List<bool> _selected = [];
  19. bool _qrGenerated = false;
  20. String _searchQuery = '';
  21. final TextEditingController _searchController = TextEditingController();
  22. @override
  23. void initState() {
  24. super.initState();
  25. WidgetsBinding.instance.addPostFrameCallback((_) => _initSelection());
  26. }
  27. @override
  28. void dispose() {
  29. _searchController.dispose();
  30. super.dispose();
  31. }
  32. void _initSelection() {
  33. final state = Provider.of<AppState>(context, listen: false);
  34. final items = state.items ?? [];
  35. setState(() {
  36. _selected = List.generate(items.length, (_) => true);
  37. _qrUris = [];
  38. _qrGenerated = false;
  39. _currentIndex = 0;
  40. });
  41. }
  42. List<BaseItemType> _filterItems(List<BaseItemType> items) {
  43. if (_searchQuery.isEmpty) return items;
  44. return items.where((item) {
  45. final nameMatch = item.totp.accountName.toLowerCase().contains(_searchQuery.toLowerCase());
  46. final issuerMatch = item.totp.issuer.toLowerCase().contains(_searchQuery.toLowerCase());
  47. return nameMatch || issuerMatch;
  48. }).toList();
  49. }
  50. void _generateQrUris() {
  51. final state = Provider.of<AppState>(context, listen: false);
  52. final allItems = state.items ?? [];
  53. final filteredItems = _filterItems(allItems);
  54. final selectedItems = <TotpItem>[];
  55. // Map filtered items back to original indices for selection tracking
  56. for (int i = 0; i < allItems.length; i++) {
  57. if (_selected[i] && filteredItems.contains(allItems[i])) {
  58. selectedItems.add(allItems[i].totp);
  59. }
  60. }
  61. if (selectedItems.isEmpty) {
  62. setState(() {
  63. _qrUris = [];
  64. _qrGenerated = true;
  65. });
  66. return;
  67. }
  68. final otpAuths = selectedItems.map((item) => _toOtpAuthUri(item)).toList();
  69. final qrUris = <String>[];
  70. for (int i = 0; i < otpAuths.length; i += maxAccountsPerBatch) {
  71. final batch = otpAuths.sublist(i, (i + maxAccountsPerBatch > otpAuths.length) ? otpAuths.length : i + maxAccountsPerBatch);
  72. final uri = OtpAuthMigration().encode(batch, batchSize: otpAuths.length, batchIndex: (i ~/ maxAccountsPerBatch), batchId: 1);
  73. qrUris.add(uri);
  74. }
  75. setState(() {
  76. _qrUris = qrUris;
  77. _qrGenerated = true;
  78. _currentIndex = 0;
  79. });
  80. if (qrUris.isNotEmpty) {
  81. _showQrDialog(qrUris);
  82. }
  83. }
  84. void _showQrDialog(List<String> qrUris) {
  85. int dialogIndex = 0;
  86. showDialog(
  87. context: context,
  88. builder: (context) {
  89. return StatefulBuilder(
  90. builder: (context, setState) {
  91. return AlertDialog(
  92. contentPadding: const EdgeInsets.all(16),
  93. content: SizedBox(
  94. width: 360,
  95. child: Column(
  96. mainAxisSize: MainAxisSize.min,
  97. children: [
  98. QrImageView(
  99. data: qrUris[dialogIndex],
  100. version: QrVersions.auto,
  101. size: 320,
  102. backgroundColor: Colors.white,
  103. errorStateBuilder: (cxt, err) => Center(child: Text('QR error', style: TextStyle(color: Colors.red))),
  104. ),
  105. if (qrUris.length > 1)
  106. Padding(
  107. padding: const EdgeInsets.only(top: 16),
  108. child: Row(
  109. mainAxisAlignment: MainAxisAlignment.center,
  110. children: [
  111. IconButton(
  112. icon: const Icon(Icons.arrow_back),
  113. onPressed: dialogIndex > 0
  114. ? () => setState(() => dialogIndex--)
  115. : null,
  116. ),
  117. Text('${dialogIndex + 1} / ${qrUris.length}'),
  118. IconButton(
  119. icon: const Icon(Icons.arrow_forward),
  120. onPressed: dialogIndex < qrUris.length - 1
  121. ? () => setState(() => dialogIndex++)
  122. : null,
  123. ),
  124. ],
  125. ),
  126. ),
  127. ],
  128. ),
  129. ),
  130. actions: [
  131. TextButton(
  132. onPressed: () => Navigator.of(context).pop(),
  133. child: const Text('Close'),
  134. ),
  135. ],
  136. );
  137. },
  138. );
  139. },
  140. );
  141. }
  142. String _toOtpAuthUri(TotpItem item) {
  143. final algorithm = item.algorithm.name.toUpperCase();
  144. 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}';
  145. }
  146. @override
  147. Widget build(BuildContext context) {
  148. final state = Provider.of<AppState>(context);
  149. final allItems = state.items ?? [];
  150. final filteredItems = _filterItems(allItems);
  151. final loc = AppLocalizations.of(context);
  152. return Scaffold(
  153. appBar: AppBar(title: Text(loc?.exportAccounts ?? 'Export accounts')),
  154. body: allItems.isEmpty
  155. ? Center(child: Text(loc?.noAccounts ?? 'No accounts to export.'))
  156. : Padding(
  157. padding: const EdgeInsets.all(16.0),
  158. child: Column(
  159. children: [
  160. Icon(Icons.qr_code, size: 48, color: Colors.blue),
  161. const SizedBox(height: 8),
  162. Text(
  163. loc?.exportDescription ?? 'Select which accounts to export and generate a QR code to transfer them to another device.',
  164. textAlign: TextAlign.center,
  165. style: Theme.of(context).textTheme.bodyMedium,
  166. ),
  167. const SizedBox(height: 16),
  168. // Search field
  169. TextField(
  170. controller: _searchController,
  171. decoration: InputDecoration(
  172. hintText: loc?.searchHint ?? 'Search by name or issuer',
  173. prefixIcon: const Icon(Icons.search),
  174. suffixIcon: _searchQuery.isNotEmpty
  175. ? IconButton(
  176. icon: const Icon(Icons.clear),
  177. onPressed: () {
  178. setState(() {
  179. _searchQuery = '';
  180. _searchController.clear();
  181. });
  182. },
  183. )
  184. : null,
  185. border: OutlineInputBorder(
  186. borderRadius: BorderRadius.circular(12),
  187. ),
  188. contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
  189. ),
  190. onChanged: (value) {
  191. setState(() {
  192. _searchQuery = value;
  193. });
  194. },
  195. ),
  196. const SizedBox(height: 16),
  197. // Select All/None buttons
  198. Row(
  199. children: [
  200. TextButton(
  201. onPressed: () {
  202. setState(() {
  203. for (int i = 0; i < allItems.length; i++) {
  204. if (filteredItems.contains(allItems[i])) {
  205. _selected[i] = true;
  206. }
  207. }
  208. });
  209. },
  210. child: Text(loc?.selectAll ?? 'Select All'),
  211. ),
  212. const SizedBox(width: 8),
  213. TextButton(
  214. onPressed: () {
  215. setState(() {
  216. for (int i = 0; i < allItems.length; i++) {
  217. if (filteredItems.contains(allItems[i])) {
  218. _selected[i] = false;
  219. }
  220. }
  221. });
  222. },
  223. child: Text(loc?.selectNone ?? 'Select None'),
  224. ),
  225. ],
  226. ),
  227. Expanded(
  228. child: filteredItems.isEmpty && _searchQuery.isNotEmpty
  229. ? Center(
  230. child: Text(
  231. loc?.noSearchResults(_searchQuery) ?? 'No accounts found for "$_searchQuery"',
  232. textAlign: TextAlign.center,
  233. style: const TextStyle(height: 1.2),
  234. ),
  235. )
  236. : ListView.builder(
  237. itemCount: filteredItems.length,
  238. itemBuilder: (context, i) {
  239. final item = filteredItems[i];
  240. final originalIndex = allItems.indexOf(item);
  241. return CheckboxListTile(
  242. value: _selected[originalIndex],
  243. onChanged: (val) {
  244. setState(() {
  245. _selected[originalIndex] = val ?? false;
  246. });
  247. },
  248. title: Text(item.totp.issuer.isNotEmpty
  249. ? '${item.totp.issuer}: ${item.totp.accountName}'
  250. : item.totp.accountName),
  251. subtitle: item.totp.issuer.isNotEmpty && item.totp.accountName.isNotEmpty
  252. ? null
  253. : Text(item.totp.issuer.isEmpty ? 'No issuer' : 'No account name'),
  254. );
  255. },
  256. ),
  257. ),
  258. ElevatedButton.icon(
  259. icon: const Icon(Icons.qr_code),
  260. label: Text(loc?.generateQr ?? 'Generate QR'),
  261. onPressed: _selected.any((s) => s) ? _generateQrUris : null,
  262. ),
  263. const SizedBox(height: 16),
  264. if (_qrGenerated && _qrUris.isEmpty)
  265. Text(loc?.noAccountsSelected ?? 'No accounts selected.'),
  266. ],
  267. ),
  268. ),
  269. );
  270. }
  271. }
  272. class BarcodePainterWidget extends StatelessWidget {
  273. final String data;
  274. const BarcodePainterWidget({super.key, required this.data});
  275. @override
  276. Widget build(BuildContext context) {
  277. return QrImageView(
  278. data: data,
  279. version: QrVersions.auto,
  280. size: 240,
  281. backgroundColor: Colors.white,
  282. errorStateBuilder: (cxt, err) => Center(child: Text('QR error', style: TextStyle(color: Colors.red))),
  283. );
  284. }
  285. }