import 'package:flutter/material.dart'; 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 '../l10n/app_localizations.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:pb_authenticator_state/state.dart'; class ExportAccountsPage extends StatefulWidget { const ExportAccountsPage({super.key}); @override State createState() => _ExportAccountsPageState(); } class _ExportAccountsPageState extends State { static const int maxAccountsPerBatch = 10; // Google Authenticator uses 10 List _qrUris = []; int _currentIndex = 0; List _selected = []; bool _qrGenerated = false; String _searchQuery = ''; final TextEditingController _searchController = TextEditingController(); @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) => _initSelection()); } @override void dispose() { _searchController.dispose(); super.dispose(); } void _initSelection() { final state = Provider.of(context, listen: false); final items = state.items ?? []; setState(() { _selected = List.generate(items.length, (_) => true); _qrUris = []; _qrGenerated = false; _currentIndex = 0; }); } List _filterItems(List items) { if (_searchQuery.isEmpty) return items; return items.where((item) { final nameMatch = item.totp.accountName.toLowerCase().contains(_searchQuery.toLowerCase()); final issuerMatch = item.totp.issuer.toLowerCase().contains(_searchQuery.toLowerCase()); return nameMatch || issuerMatch; }).toList(); } void _generateQrUris() { final state = Provider.of(context, listen: false); final allItems = state.items ?? []; final filteredItems = _filterItems(allItems); final selectedItems = []; // Map filtered items back to original indices for selection tracking for (int i = 0; i < allItems.length; i++) { if (_selected[i] && filteredItems.contains(allItems[i])) { selectedItems.add(allItems[i].totp); } } if (selectedItems.isEmpty) { setState(() { _qrUris = []; _qrGenerated = true; }); return; } final otpAuths = selectedItems.map((item) => _toOtpAuthUri(item)).toList(); final qrUris = []; 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); } setState(() { _qrUris = qrUris; _qrGenerated = true; _currentIndex = 0; }); if (qrUris.isNotEmpty) { _showQrDialog(qrUris); } } void _showQrDialog(List 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) { final algorithm = item.algorithm.name.toUpperCase(); 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}'; } @override Widget build(BuildContext context) { final state = Provider.of(context); final allItems = state.items ?? []; final filteredItems = _filterItems(allItems); final loc = AppLocalizations.of(context); return Scaffold( appBar: AppBar(title: Text(loc?.exportAccounts ?? 'Export accounts')), body: allItems.isEmpty ? Center(child: Text(loc?.noAccounts ?? 'No accounts to export.')) : Padding( padding: const EdgeInsets.all(16.0), child: Column( children: [ 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), // Search field TextField( controller: _searchController, decoration: InputDecoration( hintText: loc?.searchHint ?? 'Search by name or issuer', prefixIcon: const Icon(Icons.search), suffixIcon: _searchQuery.isNotEmpty ? IconButton( icon: const Icon(Icons.clear), onPressed: () { setState(() { _searchQuery = ''; _searchController.clear(); }); }, ) : null, border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), ), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), onChanged: (value) { setState(() { _searchQuery = value; }); }, ), const SizedBox(height: 16), // Select All/None buttons Row( children: [ TextButton( onPressed: () { setState(() { for (int i = 0; i < allItems.length; i++) { if (filteredItems.contains(allItems[i])) { _selected[i] = true; } } }); }, child: Text(loc?.selectAll ?? 'Select All'), ), const SizedBox(width: 8), TextButton( onPressed: () { setState(() { for (int i = 0; i < allItems.length; i++) { if (filteredItems.contains(allItems[i])) { _selected[i] = false; } } }); }, child: Text(loc?.selectNone ?? 'Select None'), ), ], ), Expanded( child: filteredItems.isEmpty && _searchQuery.isNotEmpty ? Center( child: Text( loc?.noSearchResults(_searchQuery) ?? 'No accounts found for "$_searchQuery"', textAlign: TextAlign.center, style: const TextStyle(height: 1.2), ), ) : ListView.builder( itemCount: filteredItems.length, itemBuilder: (context, i) { final item = filteredItems[i]; final originalIndex = allItems.indexOf(item); return CheckboxListTile( value: _selected[originalIndex], onChanged: (val) { setState(() { _selected[originalIndex] = val ?? false; }); }, title: Text(item.totp.issuer.isNotEmpty ? '${item.totp.issuer}: ${item.totp.accountName}' : item.totp.accountName), subtitle: item.totp.issuer.isNotEmpty && item.totp.accountName.isNotEmpty ? null : Text(item.totp.issuer.isEmpty ? 'No issuer' : 'No account name'), ); }, ), ), 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.'), ], ), ), ); } } class BarcodePainterWidget extends StatelessWidget { final String data; const BarcodePainterWidget({super.key, required this.data}); @override Widget build(BuildContext context) { 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))), ); } }