Browse Source

feat: tags selection by text field

Prateek Sunal 1 year ago
parent
commit
5a18fe3746

+ 4 - 0
auth/lib/ente_theme_data.dart

@@ -427,6 +427,10 @@ extension CustomColorScheme on ColorScheme {
       ? const Color.fromRGBO(246, 246, 246, 1)
       ? const Color.fromRGBO(246, 246, 246, 1)
       : const Color.fromRGBO(40, 40, 40, 0.6);
       : const Color.fromRGBO(40, 40, 40, 0.6);
 
 
+  Color get primaryColor => brightness == Brightness.light
+      ? const Color(0xFF9610D6)
+      : const Color(0xFF9610D6);
+
   EnteTheme get enteTheme =>
   EnteTheme get enteTheme =>
       brightness == Brightness.light ? lightTheme : darkTheme;
       brightness == Brightness.light ? lightTheme : darkTheme;
 
 

+ 1 - 0
auth/lib/l10n/arb/app_en.arb

@@ -20,6 +20,7 @@
   "codeIssuerHint": "Issuer",
   "codeIssuerHint": "Issuer",
   "codeSecretKeyHint": "Secret Key",
   "codeSecretKeyHint": "Secret Key",
   "codeAccountHint": "Account (you@domain.com)",
   "codeAccountHint": "Account (you@domain.com)",
+  "codeTagHint": "Tag",
   "accountKeyType": "Type of key",
   "accountKeyType": "Type of key",
   "sessionExpired": "Session expired",
   "sessionExpired": "Session expired",
   "@sessionExpired": {
   "@sessionExpired": {

+ 1 - 0
auth/lib/main.dart

@@ -37,6 +37,7 @@ import 'package:window_manager/window_manager.dart';
 final _logger = Logger("main");
 final _logger = Logger("main");
 
 
 Future<void> initSystemTray() async {
 Future<void> initSystemTray() async {
+  if (PlatformUtil.isMobile()) return;
   String path = Platform.isWindows
   String path = Platform.isWindows
       ? 'assets/icons/auth-icon.ico'
       ? 'assets/icons/auth-icon.ico'
       : 'assets/icons/auth-icon.png';
       : 'assets/icons/auth-icon.png';

+ 9 - 7
auth/lib/models/code.dart

@@ -22,9 +22,9 @@ class Code {
   final int counter;
   final int counter;
   bool? hasSynced;
   bool? hasSynced;
 
 
-  final CodeDisplay? display;
+  final CodeDisplay display;
 
 
-  bool get isPinned => display?.pinned ?? false;
+  bool get isPinned => display.pinned;
 
 
   Code(
   Code(
     this.account,
     this.account,
@@ -37,7 +37,7 @@ class Code {
     this.counter,
     this.counter,
     this.rawData, {
     this.rawData, {
     this.generatedID,
     this.generatedID,
-    this.display,
+    required this.display,
   });
   });
 
 
   Code copyWith({
   Code copyWith({
@@ -59,7 +59,7 @@ class Code {
     final Algorithm updatedAlgo = algorithm ?? this.algorithm;
     final Algorithm updatedAlgo = algorithm ?? this.algorithm;
     final Type updatedType = type ?? this.type;
     final Type updatedType = type ?? this.type;
     final int updatedCounter = counter ?? this.counter;
     final int updatedCounter = counter ?? this.counter;
-    final CodeDisplay? updatedDisplay = display ?? this.display;
+    final CodeDisplay updatedDisplay = display ?? this.display;
 
 
     return Code(
     return Code(
       updateAccount,
       updateAccount,
@@ -80,6 +80,7 @@ class Code {
     String account,
     String account,
     String issuer,
     String issuer,
     String secret,
     String secret,
+    CodeDisplay? display,
   ) {
   ) {
     return Code(
     return Code(
       account,
       account,
@@ -91,6 +92,7 @@ class Code {
       Type.totp,
       Type.totp,
       0,
       0,
       "otpauth://totp/$issuer:$account?algorithm=SHA1&digits=6&issuer=$issuer&period=30&secret=$secret",
       "otpauth://totp/$issuer:$account?algorithm=SHA1&digits=6&issuer=$issuer&period=30&secret=$secret",
+      display: display ?? CodeDisplay(),
     );
     );
   }
   }
 
 
@@ -107,7 +109,7 @@ class Code {
         _getType(uri),
         _getType(uri),
         _getCounter(uri),
         _getCounter(uri),
         rawData,
         rawData,
-        display: CodeDisplay.fromUri(uri),
+        display: CodeDisplay.fromUri(uri) ?? CodeDisplay(),
       );
       );
     } catch (e) {
     } catch (e) {
       // if account name contains # without encoding,
       // if account name contains # without encoding,
@@ -154,7 +156,7 @@ class Code {
     return jsonEncode(
     return jsonEncode(
       Uri.parse(
       Uri.parse(
         "$rawData&codeDisplay="
         "$rawData&codeDisplay="
-        "${jsonEncode((display ?? CodeDisplay()).toJson())}",
+        "${jsonEncode(display.toJson())}",
       ).toString(),
       ).toString(),
     );
     );
   }
   }
@@ -221,7 +223,7 @@ class Code {
   }
   }
 
 
   static Type _getType(Uri uri) {
   static Type _getType(Uri uri) {
-    if (uri.host == "totp" || uri.host == "steam") {
+    if (uri.host == "totp") {
       return Type.totp;
       return Type.totp;
     } else if (uri.host == "hotp") {
     } else if (uri.host == "hotp") {
       return Type.hotp;
       return Type.hotp;

+ 25 - 0
auth/lib/models/code_display.dart

@@ -1,5 +1,7 @@
 import 'dart:convert';
 import 'dart:convert';
 
 
+import 'package:flutter/foundation.dart';
+
 /// Used to store the display settings of a code.
 /// Used to store the display settings of a code.
 class CodeDisplay {
 class CodeDisplay {
   final bool pinned;
   final bool pinned;
@@ -48,6 +50,7 @@ class CodeDisplay {
       trashed: json['trashed'] ?? false,
       trashed: json['trashed'] ?? false,
       lastUsedAt: json['lastUsedAt'] ?? 0,
       lastUsedAt: json['lastUsedAt'] ?? 0,
       tapCount: json['tapCount'] ?? 0,
       tapCount: json['tapCount'] ?? 0,
+      tags: List<String>.from(json['tags'] ?? []),
     );
     );
   }
   }
 
 
@@ -65,6 +68,28 @@ class CodeDisplay {
       'trashed': trashed,
       'trashed': trashed,
       'lastUsedAt': lastUsedAt,
       'lastUsedAt': lastUsedAt,
       'tapCount': tapCount,
       'tapCount': tapCount,
+      'tags': tags,
     };
     };
   }
   }
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
+
+    return other is CodeDisplay &&
+        other.pinned == pinned &&
+        other.trashed == trashed &&
+        other.lastUsedAt == lastUsedAt &&
+        other.tapCount == tapCount &&
+        listEquals(other.tags, tags);
+  }
+
+  @override
+  int get hashCode {
+    return pinned.hashCode ^
+        trashed.hashCode ^
+        lastUsedAt.hashCode ^
+        tapCount.hashCode ^
+        tags.hashCode;
+  }
 }
 }

+ 26 - 0
auth/lib/models/codes.dart

@@ -0,0 +1,26 @@
+import 'package:ente_auth/models/code.dart';
+
+class CodeState {
+  final Code? code;
+  final String? error;
+
+  CodeState({
+    required this.code,
+    required this.error,
+  }) : assert(code != null || error != null);
+}
+
+class Codes {
+  final List<CodeState> allCodes;
+  final List<String> tags;
+
+  Codes({
+    required this.allCodes,
+    required this.tags,
+  });
+
+  List<Code> get validCodes => allCodes
+      .where((element) => element.code != null)
+      .map((e) => e.code!)
+      .toList();
+}

+ 107 - 1
auth/lib/onboarding/view/setup_enter_secret_key_page.dart

@@ -1,5 +1,6 @@
 import "package:ente_auth/l10n/l10n.dart";
 import "package:ente_auth/l10n/l10n.dart";
 import 'package:ente_auth/models/code.dart';
 import 'package:ente_auth/models/code.dart';
+import 'package:ente_auth/models/code_display.dart';
 import 'package:ente_auth/ui/components/buttons/button_widget.dart';
 import 'package:ente_auth/ui/components/buttons/button_widget.dart';
 import 'package:ente_auth/ui/components/models/button_result.dart';
 import 'package:ente_auth/ui/components/models/button_result.dart';
 import 'package:ente_auth/utils/dialog_util.dart';
 import 'package:ente_auth/utils/dialog_util.dart';
@@ -8,8 +9,9 @@ import "package:flutter/material.dart";
 
 
 class SetupEnterSecretKeyPage extends StatefulWidget {
 class SetupEnterSecretKeyPage extends StatefulWidget {
   final Code? code;
   final Code? code;
+  final List<String> tags;
 
 
-  SetupEnterSecretKeyPage({this.code, super.key});
+  SetupEnterSecretKeyPage({this.code, super.key, required this.tags});
 
 
   @override
   @override
   State<SetupEnterSecretKeyPage> createState() =>
   State<SetupEnterSecretKeyPage> createState() =>
@@ -20,7 +22,9 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
   late TextEditingController _issuerController;
   late TextEditingController _issuerController;
   late TextEditingController _accountController;
   late TextEditingController _accountController;
   late TextEditingController _secretController;
   late TextEditingController _secretController;
+  late TextEditingController _tagController;
   late bool _secretKeyObscured;
   late bool _secretKeyObscured;
+  late List<String> tags = [...?widget.code?.display.tags];
 
 
   @override
   @override
   void initState() {
   void initState() {
@@ -34,6 +38,7 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
     _secretController = TextEditingController(
     _secretController = TextEditingController(
       text: widget.code?.secret,
       text: widget.code?.secret,
     );
     );
+    _tagController = TextEditingController();
     _secretKeyObscured = widget.code != null;
     _secretKeyObscured = widget.code != null;
     super.initState();
     super.initState();
   }
   }
@@ -50,6 +55,7 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
           child: Padding(
           child: Padding(
             padding: const EdgeInsets.symmetric(vertical: 40.0, horizontal: 40),
             padding: const EdgeInsets.symmetric(vertical: 40.0, horizontal: 40),
             child: Column(
             child: Column(
+              crossAxisAlignment: CrossAxisAlignment.start,
               children: [
               children: [
                 TextFormField(
                 TextFormField(
                   // The validator receives the text that the user has entered.
                   // The validator receives the text that the user has entered.
@@ -108,6 +114,54 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
                   ),
                   ),
                   controller: _accountController,
                   controller: _accountController,
                 ),
                 ),
+                const SizedBox(
+                  height: 20,
+                ),
+                TextFormField(
+                  // The validator receives the text that the user has entered.
+                  validator: (value) {
+                    if (value == null || value.isEmpty) {
+                      return "Please enter some text";
+                    }
+                    return null;
+                  },
+                  onFieldSubmitted: (str) {
+                    if (str.trim().isEmpty) {
+                      return;
+                    }
+
+                    if (!tags.contains(str)) {
+                      tags.add(str);
+                    }
+
+                    // Clear the tag
+                    _tagController.text = "";
+                    setState(() {});
+                  },
+                  decoration: InputDecoration(
+                    hintText: l10n.codeTagHint,
+                  ),
+                  controller: _tagController,
+                ),
+                if (tags.isNotEmpty) ...[
+                  const SizedBox(height: 10),
+                  Wrap(
+                    spacing: 12,
+                    alignment: WrapAlignment.start,
+                    children: tags
+                        .map(
+                          (e) => TagChip(
+                            label: e,
+                            onTap: () {
+                              tags.remove(e);
+                              setState(() {});
+                            },
+                          ),
+                        )
+                        .toList(),
+                  ),
+                  const SizedBox(height: 10),
+                ],
                 const SizedBox(
                 const SizedBox(
                   height: 40,
                   height: 40,
                 ),
                 ),
@@ -166,16 +220,19 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
           return;
           return;
         }
         }
       }
       }
+      final CodeDisplay display = widget.code!.display.copyWith(tags: tags);
       final Code newCode = widget.code == null
       final Code newCode = widget.code == null
           ? Code.fromAccountAndSecret(
           ? Code.fromAccountAndSecret(
               account,
               account,
               issuer,
               issuer,
               secret,
               secret,
+              display,
             )
             )
           : widget.code!.copyWith(
           : widget.code!.copyWith(
               account: account,
               account: account,
               issuer: issuer,
               issuer: issuer,
               secret: secret,
               secret: secret,
+              display: display,
             );
             );
       // Verify the validity of the code
       // Verify the validity of the code
       getOTP(newCode);
       getOTP(newCode);
@@ -196,3 +253,52 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
     );
     );
   }
   }
 }
 }
+
+class TagChip extends StatelessWidget {
+  final String label;
+  final VoidCallback? onTap;
+  final bool isSelected;
+
+  const TagChip({
+    super.key,
+    required this.label,
+    this.isSelected = false,
+    this.onTap,
+  });
+
+  @override
+  Widget build(BuildContext context) {
+    return GestureDetector(
+      onTap: onTap,
+      child: Chip(
+        label: Row(
+          mainAxisSize: MainAxisSize.min,
+          children: [
+            Text(
+              label,
+              style: TextStyle(
+                color: isSelected ? Colors.white : const Color(0xFF8232E1),
+              ),
+            ),
+            if (isSelected) ...[
+              const SizedBox(width: 4),
+              const Icon(
+                Icons.check,
+                size: 16,
+                color: Colors.white,
+              ),
+            ],
+          ],
+        ),
+        backgroundColor:
+            isSelected ? const Color(0xFF722ED1) : const Color(0xFFFCF5FF),
+        side: BorderSide(
+          color: isSelected ? const Color(0xFF722ED1) : const Color(0xFFFCF5FF),
+        ),
+        shape: RoundedRectangleBorder(
+          borderRadius: BorderRadius.circular(100),
+        ),
+      ),
+    );
+  }
+}

+ 32 - 13
auth/lib/store/code_store.dart

@@ -6,6 +6,7 @@ import 'package:ente_auth/core/event_bus.dart';
 import 'package:ente_auth/events/codes_updated_event.dart';
 import 'package:ente_auth/events/codes_updated_event.dart';
 import 'package:ente_auth/models/authenticator/entity_result.dart';
 import 'package:ente_auth/models/authenticator/entity_result.dart';
 import 'package:ente_auth/models/code.dart';
 import 'package:ente_auth/models/code.dart';
+import 'package:ente_auth/models/codes.dart';
 import 'package:ente_auth/services/authenticator_service.dart';
 import 'package:ente_auth/services/authenticator_service.dart';
 import 'package:ente_auth/store/offline_authenticator_db.dart';
 import 'package:ente_auth/store/offline_authenticator_db.dart';
 import 'package:logging/logging.dart';
 import 'package:logging/logging.dart';
@@ -22,14 +23,16 @@ class CodeStore {
     _authenticatorService = AuthenticatorService.instance;
     _authenticatorService = AuthenticatorService.instance;
   }
   }
 
 
-  Future<List<Code>> getAllCodes({AccountMode? accountMode}) async {
+  Future<Codes> getAllCodes({AccountMode? accountMode}) async {
     final mode = accountMode ?? _authenticatorService.getAccountMode();
     final mode = accountMode ?? _authenticatorService.getAccountMode();
     final List<EntityResult> entities =
     final List<EntityResult> entities =
         await _authenticatorService.getEntities(mode);
         await _authenticatorService.getEntities(mode);
-    final List<Code> codes = [];
+    final List<CodeState> codes = [];
+    List<String> tags = [];
     for (final entity in entities) {
     for (final entity in entities) {
       try {
       try {
         final decodeJson = jsonDecode(entity.rawData);
         final decodeJson = jsonDecode(entity.rawData);
+
         late Code code;
         late Code code;
         if (decodeJson is String && decodeJson.startsWith('otpauth://')) {
         if (decodeJson is String && decodeJson.startsWith('otpauth://')) {
           code = Code.fromOTPAuthUrl(decodeJson);
           code = Code.fromOTPAuthUrl(decodeJson);
@@ -38,24 +41,38 @@ class CodeStore {
         }
         }
         code.generatedID = entity.generatedID;
         code.generatedID = entity.generatedID;
         code.hasSynced = entity.hasSynced;
         code.hasSynced = entity.hasSynced;
-        codes.add(code);
+        codes.add(CodeState(code: code, error: null));
+        tags.addAll(code.display.tags);
       } catch (e) {
       } catch (e) {
+        codes.add(CodeState(code: null, error: e.toString()));
         _logger.severe("Could not parse code", e);
         _logger.severe("Could not parse code", e);
       }
       }
     }
     }
 
 
     // sort codes by issuer,account
     // sort codes by issuer,account
     codes.sort((a, b) {
     codes.sort((a, b) {
-      if (b.isPinned && !a.isPinned) return 1;
-      if (!b.isPinned && a.isPinned) return -1;
+      if (a.code == null && b.code == null) return 0;
+      if (a.code == null) return 1;
+      if (b.code == null) return -1;
+
+      final firstCode = a.code!;
+      final secondCode = b.code!;
 
 
-      final issuerComparison = compareAsciiLowerCaseNatural(a.issuer, b.issuer);
+      if (secondCode.isPinned && !firstCode.isPinned) return 1;
+      if (!secondCode.isPinned && firstCode.isPinned) return -1;
+
+      final issuerComparison =
+          compareAsciiLowerCaseNatural(firstCode.issuer, secondCode.issuer);
       if (issuerComparison != 0) {
       if (issuerComparison != 0) {
         return issuerComparison;
         return issuerComparison;
       }
       }
-      return compareAsciiLowerCaseNatural(a.account, b.account);
+      return compareAsciiLowerCaseNatural(
+        firstCode.account,
+        secondCode.account,
+      );
     });
     });
-    return codes;
+    tags = tags.toSet().toList();
+    return Codes(allCodes: codes, tags: tags);
   }
   }
 
 
   Future<AddResult> addCode(
   Future<AddResult> addCode(
@@ -67,7 +84,7 @@ class CodeStore {
     final codes = await getAllCodes(accountMode: mode);
     final codes = await getAllCodes(accountMode: mode);
     bool isExistingCode = false;
     bool isExistingCode = false;
     bool hasSameCode = false;
     bool hasSameCode = false;
-    for (final existingCode in codes) {
+    for (final existingCode in codes.validCodes) {
       if (code.generatedID != null &&
       if (code.generatedID != null &&
           existingCode.generatedID == code.generatedID) {
           existingCode.generatedID == code.generatedID) {
         isExistingCode = true;
         isExistingCode = true;
@@ -124,8 +141,9 @@ class CodeStore {
       }
       }
       logger.info('start import');
       logger.info('start import');
 
 
-      List<Code> offlineCodes = await CodeStore.instance
-          .getAllCodes(accountMode: AccountMode.offline);
+      List<Code> offlineCodes = (await CodeStore.instance
+              .getAllCodes(accountMode: AccountMode.offline))
+          .validCodes;
       if (offlineCodes.isEmpty) {
       if (offlineCodes.isEmpty) {
         return;
         return;
       }
       }
@@ -134,8 +152,9 @@ class CodeStore {
         logger.info("skip as online sync is not done");
         logger.info("skip as online sync is not done");
         return;
         return;
       }
       }
-      final List<Code> onlineCodes =
-          await CodeStore.instance.getAllCodes(accountMode: AccountMode.online);
+      final List<Code> onlineCodes = (await CodeStore.instance
+              .getAllCodes(accountMode: AccountMode.online))
+          .validCodes;
       logger.info(
       logger.info(
         'importing ${offlineCodes.length} offline codes with ${onlineCodes.length} online codes',
         'importing ${offlineCodes.length} offline codes with ${onlineCodes.length} online codes',
       );
       );

+ 9 - 3
auth/lib/ui/code_timer_progress.dart

@@ -1,3 +1,4 @@
+import 'package:ente_auth/theme/ente_theme.dart';
 import 'package:ente_auth/ui/linear_progress_widget.dart';
 import 'package:ente_auth/ui/linear_progress_widget.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/scheduler.dart';
 import 'package:flutter/scheduler.dart';
@@ -47,9 +48,14 @@ class _CodeTimerProgressState extends State<CodeTimerProgress>
 
 
   @override
   @override
   Widget build(BuildContext context) {
   Widget build(BuildContext context) {
-    return LinearProgressWidget(
-      color: _progress > 0.4 ? const Color(0xFF9610D6) : Colors.orange,
-      fractionOfStorage: _progress,
+    return SizedBox(
+      height: 3,
+      child: LinearProgressWidget(
+        color: _progress > 0.4
+            ? getEnteColorScheme(context).primary700
+            : Colors.orange,
+        fractionOfStorage: _progress,
+      ),
     );
     );
   }
   }
 }
 }

+ 6 - 2
auth/lib/ui/code_widget.dart

@@ -27,8 +27,9 @@ import 'package:move_to_background/move_to_background.dart';
 
 
 class CodeWidget extends StatefulWidget {
 class CodeWidget extends StatefulWidget {
   final Code code;
   final Code code;
+  final List<String> tags;
 
 
-  const CodeWidget(this.code, {super.key});
+  const CodeWidget(this.code, this.tags, {super.key});
 
 
   @override
   @override
   State<CodeWidget> createState() => _CodeWidgetState();
   State<CodeWidget> createState() => _CodeWidgetState();
@@ -500,7 +501,10 @@ class _CodeWidgetState extends State<CodeWidget> {
     final Code? code = await Navigator.of(context).push(
     final Code? code = await Navigator.of(context).push(
       MaterialPageRoute(
       MaterialPageRoute(
         builder: (BuildContext context) {
         builder: (BuildContext context) {
-          return SetupEnterSecretKeyPage(code: widget.code);
+          return SetupEnterSecretKeyPage(
+            code: widget.code,
+            tags: widget.tags,
+          );
         },
         },
       ),
       ),
     );
     );

+ 87 - 33
auth/lib/ui/home_page.dart

@@ -10,6 +10,7 @@ import 'package:ente_auth/events/icons_changed_event.dart';
 import 'package:ente_auth/events/trigger_logout_event.dart';
 import 'package:ente_auth/events/trigger_logout_event.dart';
 import "package:ente_auth/l10n/l10n.dart";
 import "package:ente_auth/l10n/l10n.dart";
 import 'package:ente_auth/models/code.dart';
 import 'package:ente_auth/models/code.dart';
+import 'package:ente_auth/models/codes.dart';
 import 'package:ente_auth/onboarding/view/setup_enter_secret_key_page.dart';
 import 'package:ente_auth/onboarding/view/setup_enter_secret_key_page.dart';
 import 'package:ente_auth/services/preference_service.dart';
 import 'package:ente_auth/services/preference_service.dart';
 import 'package:ente_auth/services/user_service.dart';
 import 'package:ente_auth/services/user_service.dart';
@@ -54,8 +55,8 @@ class _HomePageState extends State<HomePage> {
   final FocusNode searchInputFocusNode = FocusNode();
   final FocusNode searchInputFocusNode = FocusNode();
   bool _showSearchBox = false;
   bool _showSearchBox = false;
   String _searchText = "";
   String _searchText = "";
-  List<Code> _codes = [];
-  List<Code> _filteredCodes = [];
+  Codes? _codes;
+  List<CodeState> _filteredCodes = [];
   StreamSubscription<CodesUpdatedEvent>? _streamSubscription;
   StreamSubscription<CodesUpdatedEvent>? _streamSubscription;
   StreamSubscription<TriggerLogoutEvent>? _triggerLogoutEvent;
   StreamSubscription<TriggerLogoutEvent>? _triggerLogoutEvent;
   StreamSubscription<IconsChangedEvent>? _iconsChangedEvent;
   StreamSubscription<IconsChangedEvent>? _iconsChangedEvent;
@@ -105,26 +106,28 @@ class _HomePageState extends State<HomePage> {
   }
   }
 
 
   void _applyFilteringAndRefresh() {
   void _applyFilteringAndRefresh() {
-    if (_searchText.isNotEmpty && _showSearchBox) {
+    if (_searchText.isNotEmpty && _showSearchBox && _codes != null) {
       final String val = _searchText.toLowerCase();
       final String val = _searchText.toLowerCase();
       // Prioritize issuer match above account for better UX while searching
       // Prioritize issuer match above account for better UX while searching
       // for a specific TOTP for email providers. Searching for "emailProvider" like (gmail, proton) should
       // for a specific TOTP for email providers. Searching for "emailProvider" like (gmail, proton) should
       // show the email provider first instead of other accounts where protonmail
       // show the email provider first instead of other accounts where protonmail
       // is the account name.
       // is the account name.
-      final List<Code> issuerMatch = [];
-      final List<Code> accountMatch = [];
+      final List<CodeState> issuerMatch = [];
+      final List<CodeState> accountMatch = [];
 
 
-      for (final Code code in _codes) {
-        if (code.issuer.toLowerCase().contains(val)) {
-          issuerMatch.add(code);
-        } else if (code.account.toLowerCase().contains(val)) {
-          accountMatch.add(code);
+      for (final CodeState codeState in _codes!.allCodes) {
+        if (codeState.error != null) continue;
+
+        if (codeState.code!.issuer.toLowerCase().contains(val)) {
+          issuerMatch.add(codeState);
+        } else if (codeState.code!.account.toLowerCase().contains(val)) {
+          accountMatch.add(codeState);
         }
         }
       }
       }
       _filteredCodes = issuerMatch;
       _filteredCodes = issuerMatch;
       _filteredCodes.addAll(accountMatch);
       _filteredCodes.addAll(accountMatch);
     } else {
     } else {
-      _filteredCodes = _codes;
+      _filteredCodes = _codes?.allCodes ?? [];
     }
     }
     if (mounted) {
     if (mounted) {
       setState(() {});
       setState(() {});
@@ -151,7 +154,7 @@ class _HomePageState extends State<HomePage> {
     if (code != null) {
     if (code != null) {
       await CodeStore.instance.addCode(code);
       await CodeStore.instance.addCode(code);
       // Focus the new code by searching
       // Focus the new code by searching
-      if (_codes.length > 2) {
+      if ((_codes?.allCodes.length ?? 0) > 2) {
         _focusNewCode(code);
         _focusNewCode(code);
       }
       }
     }
     }
@@ -161,7 +164,9 @@ class _HomePageState extends State<HomePage> {
     final Code? code = await Navigator.of(context).push(
     final Code? code = await Navigator.of(context).push(
       MaterialPageRoute(
       MaterialPageRoute(
         builder: (BuildContext context) {
         builder: (BuildContext context) {
-          return SetupEnterSecretKeyPage();
+          return SetupEnterSecretKeyPage(
+            tags: _codes?.tags ?? [],
+          );
         },
         },
       ),
       ),
     );
     );
@@ -219,6 +224,7 @@ class _HomePageState extends State<HomePage> {
                     focusedBorder: InputBorder.none,
                     focusedBorder: InputBorder.none,
                   ),
                   ),
                 ),
                 ),
+          centerTitle: true,
           actions: <Widget>[
           actions: <Widget>[
             IconButton(
             IconButton(
               icon: _showSearchBox
               icon: _showSearchBox
@@ -243,7 +249,7 @@ class _HomePageState extends State<HomePage> {
           ],
           ],
         ),
         ),
         floatingActionButton: !_hasLoaded ||
         floatingActionButton: !_hasLoaded ||
-                _codes.isEmpty ||
+                (_codes?.allCodes.isEmpty ?? true) ||
                 !PreferenceService.instance.hasShownCoachMark()
                 !PreferenceService.instance.hasShownCoachMark()
             ? null
             ? null
             : _getFab(),
             : _getFab(),
@@ -260,18 +266,61 @@ class _HomePageState extends State<HomePage> {
           onManuallySetupTap: _redirectToManualEntryPage,
           onManuallySetupTap: _redirectToManualEntryPage,
         );
         );
       } else {
       } else {
-        final list = AlignedGridView.count(
-          crossAxisCount: (MediaQuery.sizeOf(context).width ~/ 400)
-              .clamp(1, double.infinity)
-              .toInt(),
-          itemBuilder: ((context, index) {
-            try {
-              return ClipRect(child: CodeWidget(_filteredCodes[index]));
-            } catch (e) {
-              return const Text("Failed");
-            }
-          }),
-          itemCount: _filteredCodes.length,
+        final list = Column(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            SizedBox(
+              height: 48,
+              child: ListView.separated(
+                scrollDirection: Axis.horizontal,
+                padding:
+                    const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
+                separatorBuilder: (context, index) => const SizedBox(width: 8),
+                itemCount: _codes?.tags == null ? 0 : _codes!.tags.length + 1,
+                itemBuilder: (context, index) {
+                  if (index == 0) {
+                    return const TagChip(
+                      label: "All",
+                      isSelected: true,
+                    );
+                  }
+                  return TagChip(label: _codes!.tags[index - 1]);
+                },
+              ),
+            ),
+            Expanded(
+              child: AlignedGridView.count(
+                crossAxisCount: (MediaQuery.sizeOf(context).width ~/ 400)
+                    .clamp(1, double.infinity)
+                    .toInt(),
+                itemBuilder: ((context, index) {
+                  try {
+                    if (_filteredCodes[index].error != null) {
+                      return Center(
+                        child: Padding(
+                          padding: const EdgeInsets.all(8.0),
+                          child: Text(
+                            l10n.sorryUnableToGenCode(
+                              _filteredCodes[index].code?.issuer ?? "",
+                            ),
+                          ),
+                        ),
+                      );
+                    }
+                    return ClipRect(
+                      child: CodeWidget(
+                        _filteredCodes[index].code!,
+                        _codes?.tags ?? [],
+                      ),
+                    );
+                  } catch (e) {
+                    return const Text("Failed");
+                  }
+                }),
+                itemCount: _filteredCodes.length,
+              ),
+            ),
+          ],
         );
         );
         if (!PreferenceService.instance.hasShownCoachMark()) {
         if (!PreferenceService.instance.hasShownCoachMark()) {
           return Stack(
           return Stack(
@@ -291,17 +340,22 @@ class _HomePageState extends State<HomePage> {
                                 .clamp(1, double.infinity)
                                 .clamp(1, double.infinity)
                                 .toInt(),
                                 .toInt(),
                         itemBuilder: ((context, index) {
                         itemBuilder: ((context, index) {
-                          Code? code;
-                          try {
-                            code = _filteredCodes[index];
-                            return CodeWidget(code);
-                          } catch (e, s) {
-                            _logger.severe("code widget error", e, s);
+                          final codeState = _filteredCodes[index];
+                          if (codeState.code != null) {
+                            return CodeWidget(
+                              codeState.code!,
+                              _codes?.tags ?? [],
+                            );
+                          } else {
+                            _logger.severe(
+                              "code widget error",
+                              codeState.error,
+                            );
                             return Center(
                             return Center(
                               child: Padding(
                               child: Padding(
                                 padding: const EdgeInsets.all(8.0),
                                 padding: const EdgeInsets.all(8.0),
                                 child: Text(
                                 child: Text(
-                                  l10n.sorryUnableToGenCode(code?.issuer ?? ""),
+                                  l10n.sorryUnableToGenCode(""),
                                 ),
                                 ),
                               ),
                               ),
                             );
                             );

+ 1 - 1
auth/lib/ui/settings/data/export_widget.dart

@@ -173,7 +173,7 @@ Future<void> _exportCodes(BuildContext context, String fileContent) async {
 Future<String> _getAuthDataForExport() async {
 Future<String> _getAuthDataForExport() async {
   final codes = await CodeStore.instance.getAllCodes();
   final codes = await CodeStore.instance.getAllCodes();
   String data = "";
   String data = "";
-  for (final code in codes) {
+  for (final code in codes.validCodes) {
     data += "${code.rawData}\n";
     data += "${code.rawData}\n";
   }
   }
 
 

+ 1 - 0
auth/lib/ui/settings/data/import/bitwarden_import.dart

@@ -95,6 +95,7 @@ Future<int?> _processBitwardenExportFile(
           account,
           account,
           issuer,
           issuer,
           totp,
           totp,
+          null,
         );
         );
       }
       }
 
 

+ 3 - 2
auth/lib/ui/settings_page.dart

@@ -108,8 +108,9 @@ class SettingsPage extends StatelessWidget {
               await handleExportClick(context);
               await handleExportClick(context);
             } else {
             } else {
               if (result.action == ButtonAction.second) {
               if (result.action == ButtonAction.second) {
-                bool hasCodes =
-                    (await CodeStore.instance.getAllCodes()).isNotEmpty;
+                bool hasCodes = (await CodeStore.instance.getAllCodes())
+                    .validCodes
+                    .isNotEmpty;
                 if (hasCodes) {
                 if (hasCodes) {
                   final hasAuthenticated = await LocalAuthenticationService
                   final hasAuthenticated = await LocalAuthenticationService
                       .instance
                       .instance