Kaynağa Gözat

Add Search Feature & Update Version

Sasan Salamzadeh 11 ay önce
ebeveyn
işleme
7421adeef8

+ 14 - 0
README.md

@@ -9,6 +9,10 @@ A modern, open-source two-factor authentication (2FA) app built with Flutter. PB
 - **Time-based One-Time Passwords (TOTP)**: Securely generate 2FA codes for your accounts.
 - **QR Code Scanning**: Quickly add accounts by scanning QR codes.
 - **Manual Entry**: Add accounts manually if QR is not available.
+- **Search & Sort**: 
+  - Search accounts by name or issuer
+  - Sort accounts alphabetically (A-Z or Z-A)
+  - Real-time filtering with instant results
 - **Material Design**: Clean, modern, and responsive UI.
 - **Icon Support**: Large collection of SVG icons for popular services.
 - **Account Management**: Edit, remove, and organize your 2FA accounts.
@@ -24,6 +28,16 @@ A modern, open-source two-factor authentication (2FA) app built with Flutter. PB
 
 ---
 
+## 🔍 Search & Organization Features
+
+- **Real-time Search**: Instantly filter your accounts as you type
+- **Multi-field Search**: Search by account name or service provider/issuer
+- **Smart Sorting**: Organize accounts alphabetically in ascending or descending order
+- **Search Feedback**: Clear visual feedback when no results are found
+- **Quick Clear**: Easily clear search terms with one tap
+
+---
+
 ## 🌐 Localization & RTL
 
 - **Persian (Farsi) Support**: All UI elements are fully translated.

+ 3 - 1
lib/l10n/app_en.arb

@@ -74,5 +74,7 @@
                 "type": "String"
             }
         }
-    }
+    },
+    "selectAll": "Select All",
+    "selectNone": "Select None"
 }

+ 3 - 1
lib/l10n/app_fa.arb

@@ -74,5 +74,7 @@
                 "type": "String"
             }
         }
-    }
+    },
+    "selectAll": "انتخاب همه",
+    "selectNone": "عدم انتخاب همه"
 } 

+ 12 - 0
lib/l10n/app_localizations.dart

@@ -511,6 +511,18 @@ abstract class AppLocalizations {
   /// In en, this message translates to:
   /// **'No accounts found for \"{query}\"'**
   String noSearchResults(String query);
+
+  /// No description provided for @selectAll.
+  ///
+  /// In en, this message translates to:
+  /// **'Select All'**
+  String get selectAll;
+
+  /// No description provided for @selectNone.
+  ///
+  /// In en, this message translates to:
+  /// **'Select None'**
+  String get selectNone;
 }
 
 class _AppLocalizationsDelegate

+ 6 - 0
lib/l10n/app_localizations_en.dart

@@ -227,4 +227,10 @@ class AppLocalizationsEn extends AppLocalizations {
   String noSearchResults(String query) {
     return 'No accounts found for \"$query\"';
   }
+
+  @override
+  String get selectAll => 'Select All';
+
+  @override
+  String get selectNone => 'Select None';
 }

+ 6 - 0
lib/l10n/app_localizations_fa.dart

@@ -228,4 +228,10 @@ class AppLocalizationsFa extends AppLocalizations {
   String noSearchResults(String query) {
     return 'هیچ حسابی برای \"$query\" یافت نشد';
   }
+
+  @override
+  String get selectAll => 'انتخاب همه';
+
+  @override
+  String get selectNone => 'عدم انتخاب همه';
 }

+ 19 - 1
lib/pages/android/home.dart

@@ -22,6 +22,7 @@ class _AndroidHomePageState extends State<AndroidHomePage> {
   String _searchQuery = '';
   SortOption _currentSort = SortOption.nameAsc;
   final TextEditingController _searchController = TextEditingController();
+  bool _isSearchVisible = false;
 
   @override
   void dispose() {
@@ -29,6 +30,17 @@ class _AndroidHomePageState extends State<AndroidHomePage> {
     super.dispose();
   }
 
+  void _toggleSearch() {
+    setState(() {
+      _isSearchVisible = !_isSearchVisible;
+      if (!_isSearchVisible) {
+        // Clear search when hiding
+        _searchQuery = '';
+        _searchController.clear();
+      }
+    });
+  }
+
   List<BaseItemType> _filterAndSortItems(List<BaseItemType> items) {
     // Filter by search query
     List<BaseItemType> filteredItems = items;
@@ -341,6 +353,12 @@ class _AndroidHomePageState extends State<AndroidHomePage> {
               Navigator.pushNamed(context, AppRoutes.edit);
             },
           ),
+          // Search
+          IconButton(
+            icon: Icon(_isSearchVisible ? Icons.search_off : Icons.search),
+            tooltip: AppLocalizations.of(context)!.search,
+            onPressed: _toggleSearch,
+          ),
           // Add
           IconButton(
             icon: const Icon(Icons.add),
@@ -353,7 +371,7 @@ class _AndroidHomePageState extends State<AndroidHomePage> {
       ),
       body: Column(
         children: [
-          _buildSearchAndSort(),
+          if (_isSearchVisible) _buildSearchAndSort(),
           Expanded(child: _buildList()),
         ],
       ),

+ 118 - 21
lib/pages/export_accounts.dart

@@ -5,6 +5,7 @@ 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});
@@ -19,6 +20,8 @@ class _ExportAccountsPageState extends State<ExportAccountsPage> {
   int _currentIndex = 0;
   List<bool> _selected = [];
   bool _qrGenerated = false;
+  String _searchQuery = '';
+  final TextEditingController _searchController = TextEditingController();
 
   @override
   void initState() {
@@ -26,6 +29,12 @@ class _ExportAccountsPageState extends State<ExportAccountsPage> {
     WidgetsBinding.instance.addPostFrameCallback((_) => _initSelection());
   }
 
+  @override
+  void dispose() {
+    _searchController.dispose();
+    super.dispose();
+  }
+
   void _initSelection() {
     final state = Provider.of<AppState>(context, listen: false);
     final items = state.items ?? [];
@@ -37,13 +46,26 @@ class _ExportAccountsPageState extends State<ExportAccountsPage> {
     });
   }
 
+  List<BaseItemType> _filterItems(List<BaseItemType> 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<AppState>(context, listen: false);
-    final items = state.items ?? [];
+    final allItems = state.items ?? [];
+    final filteredItems = _filterItems(allItems);
     final selectedItems = <TotpItem>[];
-    for (int i = 0; i < items.length; i++) {
-      if (_selected[i]) {
-        selectedItems.add(items[i].totp);
+    
+    // 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) {
@@ -137,11 +159,13 @@ class _ExportAccountsPageState extends State<ExportAccountsPage> {
   @override
   Widget build(BuildContext context) {
     final state = Provider.of<AppState>(context);
-    final items = state.items ?? [];
+    final allItems = state.items ?? [];
+    final filteredItems = _filterItems(allItems);
     final loc = AppLocalizations.of(context);
+    
     return Scaffold(
       appBar: AppBar(title: Text(loc?.exportAccounts ?? 'Export accounts')),
-      body: items.isEmpty
+      body: allItems.isEmpty
           ? Center(child: Text(loc?.noAccounts ?? 'No accounts to export.'))
           : Padding(
               padding: const EdgeInsets.all(16.0),
@@ -155,22 +179,95 @@ class _ExportAccountsPageState extends State<ExportAccountsPage> {
                     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}'),
-                        );
-                      },
+                  // 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),

+ 2 - 2
pubspec.lock

@@ -483,14 +483,14 @@ packages:
       path: state
       relative: true
     source: path
-    version: "1.2.0"
+    version: "1.2.2"
   pb_authenticator_totp:
     dependency: "direct main"
     description:
       path: totp
       relative: true
     source: path
-    version: "1.2.0"
+    version: "1.2.2"
   petitparser:
     dependency: transitive
     description:

+ 1 - 1
pubspec.yaml

@@ -1,7 +1,7 @@
 name: pb_authenticator
 description: "PB two-factor authentication app."
 publish_to: 'none'
-version: 1.2.0+1
+version: 1.2.2+1
 
 environment:
   sdk: ^3.5.3

+ 1 - 1
state/pubspec.yaml

@@ -1,6 +1,6 @@
 name: pb_authenticator_state
 description: State for PB Authenticator.
-version: 1.2.0
+version: 1.2.2
 publish_to: none
 
 environment:

+ 1 - 1
totp/pubspec.yaml

@@ -1,6 +1,6 @@
 name: pb_authenticator_totp
 description: TOTP library used by pb_authenticator.
-version: 1.2.0
+version: 1.2.2
 publish_to: none
 
 environment: