diff --git a/auth/assets/svg/button-tint.svg b/auth/assets/svg/button-tint.svg new file mode 100644 index 000000000..1751aece1 --- /dev/null +++ b/auth/assets/svg/button-tint.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/auth/lib/main.dart b/auth/lib/main.dart index ae1bbd69c..9fa2841ff 100644 --- a/auth/lib/main.dart +++ b/auth/lib/main.dart @@ -17,6 +17,7 @@ import 'package:ente_auth/services/update_service.dart'; import 'package:ente_auth/services/user_remote_flag_service.dart'; import 'package:ente_auth/services/user_service.dart'; import 'package:ente_auth/services/window_listener_service.dart'; +import 'package:ente_auth/store/code_display_store.dart'; import 'package:ente_auth/store/code_store.dart'; import 'package:ente_auth/ui/tools/app_lock.dart'; import 'package:ente_auth/ui/tools/lock_screen.dart'; @@ -145,6 +146,7 @@ Future _init(bool bool, {String? via}) async { await PreferenceService.instance.init(); await CodeStore.instance.init(); + await CodeDisplayStore.instance.init(); await Configuration.instance.init(); await Network.instance.init(); await UserService.instance.init(); diff --git a/auth/lib/models/codes.dart b/auth/lib/models/codes.dart index 249b36394..898ed78f9 100644 --- a/auth/lib/models/codes.dart +++ b/auth/lib/models/codes.dart @@ -1,26 +1,13 @@ import 'package:ente_auth/models/code.dart'; -class CodeState { - final Code? code; - final String? error; +class AllCodes { + final List codes; + final AllCodesState state; - CodeState({ - required this.code, - required this.error, - }) : assert(code != null || error != null); + AllCodes({required this.codes, required this.state}); } -class Codes { - final List allCodes; - final List tags; - - Codes({ - required this.allCodes, - required this.tags, - }); - - List get validCodes => allCodes - .where((element) => element.code != null) - .map((e) => e.code!) - .toList(); +enum AllCodesState { + value, + error, } diff --git a/auth/lib/onboarding/model/tag_enums.dart b/auth/lib/onboarding/model/tag_enums.dart new file mode 100644 index 000000000..6661b6770 --- /dev/null +++ b/auth/lib/onboarding/model/tag_enums.dart @@ -0,0 +1,10 @@ +enum TagChipState { + selected, + unselected, +} + +enum TagChipAction { + none, + menu, + check, +} diff --git a/auth/lib/onboarding/view/common/add_chip.dart b/auth/lib/onboarding/view/common/add_chip.dart new file mode 100644 index 000000000..a93e5c392 --- /dev/null +++ b/auth/lib/onboarding/view/common/add_chip.dart @@ -0,0 +1,27 @@ +import "package:flutter/material.dart"; + +class AddChip extends StatelessWidget { + final VoidCallback? onTap; + + const AddChip({ + super.key, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Icon( + Icons.add_circle_outline, + size: 30, + color: Theme.of(context).brightness == Brightness.dark + ? const Color(0xFF9610D6) + : const Color(0xFF8232E1), + ), + ), + ); + } +} diff --git a/auth/lib/onboarding/view/common/add_tag.dart b/auth/lib/onboarding/view/common/add_tag.dart new file mode 100644 index 000000000..3fb42071e --- /dev/null +++ b/auth/lib/onboarding/view/common/add_tag.dart @@ -0,0 +1,77 @@ +import "package:ente_auth/l10n/l10n.dart"; +import "package:flutter/material.dart"; + +class AddTagDialog extends StatefulWidget { + const AddTagDialog({ + super.key, + required this.onTap, + }); + + final void Function(String) onTap; + + @override + State createState() => _AddTagDialogState(); +} + +class _AddTagDialogState extends State { + String _tag = ""; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return AlertDialog( + title: Text(l10n.createNewTag), + content: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + decoration: InputDecoration( + hintText: l10n.tag, + hintStyle: const TextStyle( + color: Colors.white30, + ), + contentPadding: const EdgeInsets.all(12), + ), + onChanged: (value) { + setState(() { + _tag = value; + }); + }, + autocorrect: false, + initialValue: _tag, + autofocus: true, + ), + ], + ), + ), + actions: [ + TextButton( + child: Text( + l10n.cancel, + style: const TextStyle( + color: Colors.redAccent, + ), + ), + onPressed: () { + Navigator.pop(context); + }, + ), + TextButton( + child: Text( + l10n.create, + style: const TextStyle( + color: Colors.purple, + ), + ), + onPressed: () { + if (_tag.trim().isEmpty) return; + + widget.onTap(_tag); + }, + ), + ], + ); + } +} diff --git a/auth/lib/onboarding/view/common/edit_tag.dart b/auth/lib/onboarding/view/common/edit_tag.dart new file mode 100644 index 000000000..fe1f38564 --- /dev/null +++ b/auth/lib/onboarding/view/common/edit_tag.dart @@ -0,0 +1,89 @@ +import "package:ente_auth/l10n/l10n.dart"; +import 'package:ente_auth/store/code_display_store.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:flutter/material.dart'; + +class EditTagDialog extends StatefulWidget { + const EditTagDialog({ + super.key, + required this.tag, + }); + + final String tag; + + @override + State createState() => _EditTagDialogState(); +} + +class _EditTagDialogState extends State { + late String _tag = widget.tag; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return AlertDialog( + title: Text(l10n.editTag), + content: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + decoration: InputDecoration( + hintText: l10n.tag, + hintStyle: const TextStyle( + color: Colors.white30, + ), + contentPadding: const EdgeInsets.all(12), + ), + onChanged: (value) { + setState(() { + _tag = value; + }); + }, + autocorrect: false, + initialValue: _tag, + autofocus: true, + ), + ], + ), + ), + actions: [ + TextButton( + child: Text( + l10n.cancel, + style: const TextStyle( + color: Colors.redAccent, + ), + ), + onPressed: () { + Navigator.pop(context); + }, + ), + TextButton( + child: Text( + l10n.saveAction, + style: const TextStyle( + color: Colors.purple, + ), + ), + onPressed: () async { + if (_tag.trim().isEmpty) return; + + final dialog = createProgressDialog( + context, + context.l10n.pleaseWait, + ); + await dialog.show(); + + await CodeDisplayStore.instance.editTag(widget.tag, _tag); + + await dialog.hide(); + + Navigator.pop(context); + }, + ), + ], + ); + } +} diff --git a/auth/lib/onboarding/view/common/tag_chip.dart b/auth/lib/onboarding/view/common/tag_chip.dart new file mode 100644 index 000000000..efd246cfe --- /dev/null +++ b/auth/lib/onboarding/view/common/tag_chip.dart @@ -0,0 +1,145 @@ +import "package:ente_auth/l10n/l10n.dart"; +import "package:ente_auth/onboarding/model/tag_enums.dart"; +import "package:ente_auth/store/code_display_store.dart"; +import "package:flutter/material.dart"; +import "package:gradient_borders/box_borders/gradient_box_border.dart"; + +class TagChip extends StatelessWidget { + final String label; + final VoidCallback? onTap; + final TagChipState state; + final TagChipAction action; + + const TagChip({ + super.key, + required this.label, + this.state = TagChipState.unselected, + this.action = TagChipAction.none, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + decoration: BoxDecoration( + color: state == TagChipState.selected + ? const Color(0xFF722ED1) + : Theme.of(context).brightness == Brightness.dark + ? const Color(0xFF1C0F22) + : const Color(0xFFFCF5FF), + borderRadius: BorderRadius.circular(100), + border: GradientBoxBorder( + gradient: LinearGradient( + colors: state == TagChipState.selected + ? [ + const Color(0xFFB37FEB), + const Color(0xFFAE40E3).withOpacity( + Theme.of(context).brightness == Brightness.dark + ? .53 + : 1, + ), + ] + : [ + Theme.of(context).brightness == Brightness.dark + ? const Color(0xFFAD00FF) + : const Color(0xFFAD00FF).withOpacity(0.2), + Theme.of(context).brightness == Brightness.dark + ? const Color(0xFFA269BD).withOpacity(0.53) + : const Color(0xFF8609C2).withOpacity(0.2), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + ), + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 16) + .copyWith(right: 0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: TextStyle( + color: state == TagChipState.selected || + Theme.of(context).brightness == Brightness.dark + ? Colors.white + : const Color(0xFF8232E1), + ), + ), + if (state == TagChipState.selected && + action == TagChipAction.check) ...[ + const SizedBox(width: 16), + const Icon( + Icons.check, + size: 16, + color: Colors.white, + ), + const SizedBox(width: 16), + ] else if (state == TagChipState.selected && + action == TagChipAction.menu) ...[ + SizedBox( + width: 48, + child: PopupMenuButton( + iconSize: 16, + padding: const EdgeInsets.symmetric(horizontal: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + surfaceTintColor: Theme.of(context).cardColor, + iconColor: Colors.white, + initialValue: -1, + onSelected: (value) { + if (value == 0) { + CodeDisplayStore.instance.showEditDialog(context, label); + } else if (value == 1) { + CodeDisplayStore.instance + .showDeleteTagDialog(context, label); + } + }, + itemBuilder: (BuildContext context) { + return [ + PopupMenuItem( + child: Row( + children: [ + const Icon(Icons.edit_outlined, size: 16), + const SizedBox(width: 12), + Text(context.l10n.edit), + ], + ), + value: 0, + ), + PopupMenuItem( + child: Row( + children: [ + const Icon( + Icons.delete_outline, + size: 16, + color: Color(0xFFF53434), + ), + const SizedBox(width: 12), + Text( + context.l10n.delete, + style: const TextStyle( + color: Color(0xFFF53434), + ), + ), + ], + ), + value: 1, + ), + ]; + }, + ), + ), + ] else ...[ + const SizedBox(width: 16), + ], + ], + ), + ), + ); + } +} diff --git a/auth/lib/onboarding/view/setup_enter_secret_key_page.dart b/auth/lib/onboarding/view/setup_enter_secret_key_page.dart index d690bcfad..4993567cf 100644 --- a/auth/lib/onboarding/view/setup_enter_secret_key_page.dart +++ b/auth/lib/onboarding/view/setup_enter_secret_key_page.dart @@ -1,19 +1,25 @@ +import 'dart:async'; + +import 'package:ente_auth/core/event_bus.dart'; +import 'package:ente_auth/events/codes_updated_event.dart'; import "package:ente_auth/l10n/l10n.dart"; import 'package:ente_auth/models/code.dart'; import 'package:ente_auth/models/code_display.dart'; -import 'package:ente_auth/store/code_store.dart'; +import 'package:ente_auth/onboarding/model/tag_enums.dart'; +import 'package:ente_auth/onboarding/view/common/add_chip.dart'; +import 'package:ente_auth/onboarding/view/common/add_tag.dart'; +import 'package:ente_auth/onboarding/view/common/tag_chip.dart'; +import 'package:ente_auth/store/code_display_store.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/utils/dialog_util.dart'; import 'package:ente_auth/utils/totp_util.dart'; import "package:flutter/material.dart"; -import 'package:gradient_borders/box_borders/gradient_box_border.dart'; class SetupEnterSecretKeyPage extends StatefulWidget { final Code? code; - final List tags; - SetupEnterSecretKeyPage({this.code, super.key, required this.tags}); + SetupEnterSecretKeyPage({this.code, super.key}); @override State createState() => @@ -26,7 +32,8 @@ class _SetupEnterSecretKeyPageState extends State { late TextEditingController _secretController; late bool _secretKeyObscured; late List tags = [...?widget.code?.display.tags]; - late List allTags = [...widget.tags]; + List allTags = []; + StreamSubscription? _streamSubscription; @override void initState() { @@ -41,9 +48,24 @@ class _SetupEnterSecretKeyPageState extends State { text: widget.code?.secret, ); _secretKeyObscured = widget.code != null; + _loadTags(); + _streamSubscription = Bus.instance.on().listen((event) { + _loadTags(); + }); super.initState(); } + @override + void dispose() { + _streamSubscription?.cancel(); + super.dispose(); + } + + Future _loadTags() async { + allTags = await CodeDisplayStore.instance.getAllTags(); + setState(() {}); + } + @override Widget build(BuildContext context) { final l10n = context.l10n; @@ -261,403 +283,3 @@ class _SetupEnterSecretKeyPageState extends State { ); } } - -class AddChip extends StatelessWidget { - final VoidCallback? onTap; - - const AddChip({ - super.key, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Icon( - Icons.add_circle_outline, - size: 30, - color: Theme.of(context).brightness == Brightness.dark - ? const Color(0xFF9610D6) - : const Color(0xFF8232E1), - ), - ), - ); - } -} - -enum TagChipState { - selected, - unselected, -} - -enum TagChipAction { - none, - menu, - check, -} - -class TagChip extends StatelessWidget { - final String label; - final VoidCallback? onTap; - final TagChipState state; - final TagChipAction action; - - const TagChip({ - super.key, - required this.label, - this.state = TagChipState.unselected, - this.action = TagChipAction.none, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - decoration: BoxDecoration( - color: state == TagChipState.selected - ? const Color(0xFF722ED1) - : Theme.of(context).brightness == Brightness.dark - ? const Color(0xFF1C0F22) - : const Color(0xFFFCF5FF), - borderRadius: BorderRadius.circular(100), - border: GradientBoxBorder( - gradient: LinearGradient( - colors: state == TagChipState.selected - ? [ - const Color(0xFFB37FEB), - const Color(0xFFAE40E3).withOpacity( - Theme.of(context).brightness == Brightness.dark - ? .53 - : 1, - ), - ] - : [ - Theme.of(context).brightness == Brightness.dark - ? const Color(0xFFAD00FF) - : const Color(0xFFAD00FF).withOpacity(0.2), - Theme.of(context).brightness == Brightness.dark - ? const Color(0xFFA269BD).withOpacity(0.53) - : const Color(0xFF8609C2).withOpacity(0.2), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - ), - margin: const EdgeInsets.symmetric(vertical: 4), - padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 16) - .copyWith(right: 0), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - label, - style: TextStyle( - color: state == TagChipState.selected || - Theme.of(context).brightness == Brightness.dark - ? Colors.white - : const Color(0xFF8232E1), - ), - ), - if (state == TagChipState.selected && - action == TagChipAction.check) ...[ - const SizedBox(width: 16), - const Icon( - Icons.check, - size: 16, - color: Colors.white, - ), - const SizedBox(width: 16), - ] else if (state == TagChipState.selected && - action == TagChipAction.menu) ...[ - SizedBox( - width: 48, - child: PopupMenuButton( - iconSize: 16, - padding: const EdgeInsets.symmetric(horizontal: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - surfaceTintColor: Theme.of(context).cardColor, - iconColor: Colors.white, - initialValue: -1, - onSelected: (value) { - if (value == 0) { - showEditDialog(context, label); - } else if (value == 1) { - showDeleteTagDialog(context, label); - } - }, - itemBuilder: (BuildContext context) { - return [ - PopupMenuItem( - child: Row( - children: [ - const Icon(Icons.edit_outlined, size: 16), - const SizedBox(width: 12), - Text(context.l10n.edit), - ], - ), - value: 0, - ), - PopupMenuItem( - child: Row( - children: [ - const Icon( - Icons.delete_outline, - size: 16, - color: Color(0xFFF53434), - ), - const SizedBox(width: 12), - Text( - context.l10n.delete, - style: const TextStyle( - color: Color(0xFFF53434), - ), - ), - ], - ), - value: 1, - ), - ]; - }, - ), - ), - ] else ...[ - const SizedBox(width: 16), - ], - ], - ), - ), - ); - } -} - -class AddTagDialog extends StatefulWidget { - const AddTagDialog({ - super.key, - required this.onTap, - }); - - final void Function(String) onTap; - - @override - State createState() => _AddTagDialogState(); -} - -class _AddTagDialogState extends State { - String _tag = ""; - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - return AlertDialog( - title: Text(l10n.createNewTag), - content: SingleChildScrollView( - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextFormField( - decoration: InputDecoration( - hintText: l10n.tag, - hintStyle: const TextStyle( - color: Colors.white30, - ), - contentPadding: const EdgeInsets.all(12), - ), - onChanged: (value) { - setState(() { - _tag = value; - }); - }, - autocorrect: false, - initialValue: _tag, - autofocus: true, - ), - ], - ), - ), - actions: [ - TextButton( - child: Text( - l10n.cancel, - style: const TextStyle( - color: Colors.redAccent, - ), - ), - onPressed: () { - Navigator.pop(context); - }, - ), - TextButton( - child: Text( - l10n.create, - style: const TextStyle( - color: Colors.purple, - ), - ), - onPressed: () { - if (_tag.trim().isEmpty) return; - - widget.onTap(_tag); - }, - ), - ], - ); - } -} - -class EditTagDialog extends StatefulWidget { - const EditTagDialog({ - super.key, - required this.tag, - }); - - final String tag; - - @override - State createState() => _EditTagDialogState(); -} - -class _EditTagDialogState extends State { - late String _tag = widget.tag; - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - return AlertDialog( - title: Text(l10n.editTag), - content: SingleChildScrollView( - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextFormField( - decoration: InputDecoration( - hintText: l10n.tag, - hintStyle: const TextStyle( - color: Colors.white30, - ), - contentPadding: const EdgeInsets.all(12), - ), - onChanged: (value) { - setState(() { - _tag = value; - }); - }, - autocorrect: false, - initialValue: _tag, - autofocus: true, - ), - ], - ), - ), - actions: [ - TextButton( - child: Text( - l10n.cancel, - style: const TextStyle( - color: Colors.redAccent, - ), - ), - onPressed: () { - Navigator.pop(context); - }, - ), - TextButton( - child: Text( - l10n.saveAction, - style: const TextStyle( - color: Colors.purple, - ), - ), - onPressed: () async { - if (_tag.trim().isEmpty) return; - - final dialog = createProgressDialog( - context, - context.l10n.pleaseWait, - ); - await dialog.show(); - - // traverse through all the codes and edit this tag's value - final relevantCodes = (await CodeStore.instance.getAllCodes()) - .validCodes - .where((element) => element.display.tags.contains(widget.tag)); - - final tasks = []; - - for (final code in relevantCodes) { - final tags = code.display.tags; - tags.remove(widget.tag); - tags.add(_tag); - tasks.add( - CodeStore.instance.addCode( - code.copyWith( - display: code.display.copyWith(tags: tags), - ), - ), - ); - } - - await Future.wait(tasks); - await dialog.hide(); - - Navigator.pop(context); - }, - ), - ], - ); - } -} - -Future showDeleteTagDialog(BuildContext context, String tag) async { - FocusScope.of(context).requestFocus(); - final l10n = context.l10n; - await showChoiceActionSheet( - context, - title: l10n.deleteTagTitle, - body: l10n.deleteTagMessage, - firstButtonLabel: l10n.delete, - isCritical: true, - firstButtonOnTap: () async { - // traverse through all the codes and edit this tag's value - final relevantCodes = (await CodeStore.instance.getAllCodes()) - .validCodes - .where((element) => element.display.tags.contains(tag)); - - final tasks = []; - - for (final code in relevantCodes) { - final tags = code.display.tags; - tags.remove(tag); - tasks.add( - CodeStore.instance.addCode( - code.copyWith( - display: code.display.copyWith(tags: tags), - ), - ), - ); - } - - await Future.wait(tasks); - }, - ); -} - -Future showEditDialog(BuildContext context, String tag) async { - await showDialog( - context: context, - builder: (BuildContext context) { - return EditTagDialog(tag: tag); - }, - barrierColor: Colors.black.withOpacity(0.85), - barrierDismissible: false, - ); -} diff --git a/auth/lib/store/code_display_store.dart b/auth/lib/store/code_display_store.dart new file mode 100644 index 000000000..37a1cf969 --- /dev/null +++ b/auth/lib/store/code_display_store.dart @@ -0,0 +1,123 @@ +import 'dart:convert'; + +import 'package:ente_auth/l10n/l10n.dart'; +import 'package:ente_auth/models/authenticator/entity_result.dart'; +import 'package:ente_auth/models/code.dart'; +import 'package:ente_auth/onboarding/view/common/edit_tag.dart'; +import 'package:ente_auth/services/authenticator_service.dart'; +import 'package:ente_auth/store/code_store.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; + +class CodeDisplayStore { + static final CodeDisplayStore instance = + CodeDisplayStore._privateConstructor(); + + CodeDisplayStore._privateConstructor(); + + late CodeStore _codeStore; + + late AuthenticatorService _authenticatorService; + final _logger = Logger("CodeDisplayStore"); + + Future init() async { + _authenticatorService = AuthenticatorService.instance; + _codeStore = CodeStore.instance; + } + + Future> getAllTags({AccountMode? accountMode}) async { + final mode = accountMode ?? _authenticatorService.getAccountMode(); + final List entities = + await _authenticatorService.getEntities(mode); + List tags = []; + + for (final entity in entities) { + try { + final decodeJson = jsonDecode(entity.rawData); + + late Code code; + if (decodeJson is String && decodeJson.startsWith('otpauth://')) { + code = Code.fromOTPAuthUrl(decodeJson); + } else { + code = Code.fromExportJson(decodeJson); + } + tags.addAll(code.display.tags); + } catch (e) { + _logger.severe("Could not parse code", e); + } + } + tags = tags.toSet().toList(); + return tags; + } + + Future showDeleteTagDialog(BuildContext context, String tag) async { + FocusScope.of(context).requestFocus(); + final l10n = context.l10n; + + await showChoiceActionSheet( + context, + title: l10n.deleteTagTitle, + body: l10n.deleteTagMessage, + firstButtonLabel: l10n.delete, + isCritical: true, + firstButtonOnTap: () async { + // traverse through all the codes and edit this tag's value + final relevantCodes = (await CodeStore.instance.getAllCodes()) + .codes + .where((element) => element.display.tags.contains(tag)); + + final tasks = []; + + for (final code in relevantCodes) { + final tags = code.display.tags; + tags.remove(tag); + tasks.add( + _codeStore.addCode( + code.copyWith( + display: code.display.copyWith(tags: tags), + ), + ), + ); + } + + await Future.wait(tasks); + }, + ); + } + + Future showEditDialog(BuildContext context, String tag) async { + await showDialog( + context: context, + builder: (BuildContext context) { + return EditTagDialog(tag: tag); + }, + barrierColor: Colors.black.withOpacity(0.85), + barrierDismissible: false, + ); + } + + Future editTag(String previousTag, String updatedTag) async { + // traverse through all the codes and edit this tag's value + final relevantCodes = (await CodeStore.instance.getAllCodes()) + .codes + .where((element) => element.display.tags.contains(previousTag)); + + final tasks = []; + + for (final code in relevantCodes) { + final tags = code.display.tags; + tags.remove(previousTag); + tags.add(updatedTag); + tasks.add( + CodeStore.instance.addCode( + code.copyWith( + display: code.display.copyWith(tags: tags), + ), + ), + ); + } + + await Future.wait(tasks); + } +} diff --git a/auth/lib/store/code_store.dart b/auth/lib/store/code_store.dart index 4028af4f0..3ed6d6f3e 100644 --- a/auth/lib/store/code_store.dart +++ b/auth/lib/store/code_store.dart @@ -23,12 +23,13 @@ class CodeStore { _authenticatorService = AuthenticatorService.instance; } - Future getAllCodes({AccountMode? accountMode}) async { + Future getAllCodes({AccountMode? accountMode}) async { final mode = accountMode ?? _authenticatorService.getAccountMode(); final List entities = await _authenticatorService.getEntities(mode); - final List codes = []; - List tags = []; + final List codes = []; + bool hasError = false; + for (final entity in entities) { try { final decodeJson = jsonDecode(entity.rawData); @@ -41,23 +42,15 @@ class CodeStore { } code.generatedID = entity.generatedID; code.hasSynced = entity.hasSynced; - codes.add(CodeState(code: code, error: null)); - tags.addAll(code.display.tags); + codes.add(code); } catch (e) { - codes.add(CodeState(code: null, error: e.toString())); + hasError = true; _logger.severe("Could not parse code", e); } } // sort codes by issuer,account - codes.sort((a, b) { - 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!; - + codes.sort((firstCode, secondCode) { if (secondCode.isPinned && !firstCode.isPinned) return 1; if (!secondCode.isPinned && firstCode.isPinned) return -1; @@ -71,8 +64,10 @@ class CodeStore { secondCode.account, ); }); - tags = tags.toSet().toList(); - return Codes(allCodes: codes, tags: tags); + return AllCodes( + codes: codes, + state: hasError ? AllCodesState.error : AllCodesState.value, + ); } Future addCode( @@ -81,10 +76,10 @@ class CodeStore { AccountMode? accountMode, }) async { final mode = accountMode ?? _authenticatorService.getAccountMode(); - final codes = await getAllCodes(accountMode: mode); + final allCodes = await getAllCodes(accountMode: mode); bool isExistingCode = false; bool hasSameCode = false; - for (final existingCode in codes.validCodes) { + for (final existingCode in allCodes.codes) { if (code.generatedID != null && existingCode.generatedID == code.generatedID) { isExistingCode = true; @@ -143,7 +138,7 @@ class CodeStore { List offlineCodes = (await CodeStore.instance .getAllCodes(accountMode: AccountMode.offline)) - .validCodes; + .codes; if (offlineCodes.isEmpty) { return; } @@ -154,7 +149,7 @@ class CodeStore { } final List onlineCodes = (await CodeStore.instance .getAllCodes(accountMode: AccountMode.online)) - .validCodes; + .codes; logger.info( 'importing ${offlineCodes.length} offline codes with ${onlineCodes.length} online codes', ); diff --git a/auth/lib/ui/code_widget.dart b/auth/lib/ui/code_widget.dart index 3504da333..a350f5ee4 100644 --- a/auth/lib/ui/code_widget.dart +++ b/auth/lib/ui/code_widget.dart @@ -28,9 +28,13 @@ import 'package:move_to_background/move_to_background.dart'; class CodeWidget extends StatefulWidget { final Code code; - final List tags; + final bool hasError; - const CodeWidget(this.code, this.tags, {super.key}); + const CodeWidget( + this.code, { + super.key, + this.hasError = false, + }); @override State createState() => _CodeWidgetState(); @@ -88,135 +92,145 @@ class _CodeWidgetState extends State { _isInitialized = true; } final l10n = context.l10n; - return Container( - margin: const EdgeInsets.only(left: 16, right: 16, bottom: 8, top: 8), - child: Builder( - builder: (context) { - if (PlatformUtil.isDesktop()) { - return ContextMenuRegion( - contextMenu: ContextMenu( - entries: [ - MenuItem( - label: 'QR', - icon: Icons.qr_code_2_outlined, - onSelected: () => _onShowQrPressed(null), - ), - MenuItem( - label: widget.code.isPinned ? l10n.unpinText : l10n.pinText, - icon: widget.code.isPinned - ? Icons.push_pin - : Icons.push_pin_outlined, - onSelected: () => _onShowQrPressed(null), - ), - MenuItem( - label: l10n.edit, - icon: Icons.edit, - onSelected: () => _onEditPressed(null), - ), - const MenuDivider(), - MenuItem( - label: l10n.delete, - value: "Delete", - icon: Icons.delete, - onSelected: () => _onDeletePressed(null), - ), - ], - padding: const EdgeInsets.all(8.0), - ), - child: _clippedCard(l10n), - ); - } - - return Slidable( - key: ValueKey(widget.code.hashCode), - endActionPane: ActionPane( - extentRatio: 0.90, - motion: const ScrollMotion(), - children: [ - const SizedBox( - width: 14, - ), - SlidableAction( - onPressed: _onShowQrPressed, - backgroundColor: Colors.grey.withOpacity(0.1), - borderRadius: const BorderRadius.all(Radius.circular(8)), - foregroundColor: - Theme.of(context).colorScheme.inverseBackgroundColor, - icon: Icons.qr_code_2_outlined, - label: "QR", - padding: const EdgeInsets.only(left: 4, right: 0), - spacing: 8, - ), - const SizedBox( - width: 14, - ), - CustomSlidableAction( - onPressed: _onPinPressed, - backgroundColor: Colors.grey.withOpacity(0.1), - borderRadius: const BorderRadius.all(Radius.circular(8)), - foregroundColor: - Theme.of(context).colorScheme.inverseBackgroundColor, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (widget.code.isPinned) - SvgPicture.asset( - "assets/svg/pin-active.svg", - colorFilter: ui.ColorFilter.mode( - Theme.of(context).colorScheme.primary, - BlendMode.srcIn, - ), - ) - else - SvgPicture.asset( - "assets/svg/pin-inactive.svg", - colorFilter: ui.ColorFilter.mode( - Theme.of(context).colorScheme.primary, - BlendMode.srcIn, - ), - ), - const SizedBox(height: 8), - Text( - widget.code.isPinned ? l10n.unpinText : l10n.pinText, + return IgnorePointer( + ignoring: widget.hasError, + child: Opacity( + opacity: widget.hasError ? 0.5 : 1.0, + child: Container( + margin: const EdgeInsets.only(left: 16, right: 16, bottom: 8, top: 8), + child: Builder( + builder: (context) { + if (PlatformUtil.isDesktop()) { + return ContextMenuRegion( + contextMenu: ContextMenu( + entries: [ + MenuItem( + label: 'QR', + icon: Icons.qr_code_2_outlined, + onSelected: () => _onShowQrPressed(null), + ), + MenuItem( + label: widget.code.isPinned + ? l10n.unpinText + : l10n.pinText, + icon: widget.code.isPinned + ? Icons.push_pin + : Icons.push_pin_outlined, + onSelected: () => _onShowQrPressed(null), + ), + MenuItem( + label: l10n.edit, + icon: Icons.edit, + onSelected: () => _onEditPressed(null), + ), + const MenuDivider(), + MenuItem( + label: l10n.delete, + value: "Delete", + icon: Icons.delete, + onSelected: () => _onDeletePressed(null), ), ], + padding: const EdgeInsets.all(8.0), ), - padding: const EdgeInsets.only(left: 4, right: 0), + child: _clippedCard(l10n), + ); + } + + return Slidable( + key: ValueKey(widget.code.hashCode), + endActionPane: ActionPane( + extentRatio: 0.90, + motion: const ScrollMotion(), + children: [ + const SizedBox( + width: 14, + ), + SlidableAction( + onPressed: _onShowQrPressed, + backgroundColor: Colors.grey.withOpacity(0.1), + borderRadius: const BorderRadius.all(Radius.circular(8)), + foregroundColor: + Theme.of(context).colorScheme.inverseBackgroundColor, + icon: Icons.qr_code_2_outlined, + label: "QR", + padding: const EdgeInsets.only(left: 4, right: 0), + spacing: 8, + ), + const SizedBox( + width: 14, + ), + CustomSlidableAction( + onPressed: _onPinPressed, + backgroundColor: Colors.grey.withOpacity(0.1), + borderRadius: const BorderRadius.all(Radius.circular(8)), + foregroundColor: + Theme.of(context).colorScheme.inverseBackgroundColor, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.code.isPinned) + SvgPicture.asset( + "assets/svg/pin-active.svg", + colorFilter: ui.ColorFilter.mode( + Theme.of(context).colorScheme.primary, + BlendMode.srcIn, + ), + ) + else + SvgPicture.asset( + "assets/svg/pin-inactive.svg", + colorFilter: ui.ColorFilter.mode( + Theme.of(context).colorScheme.primary, + BlendMode.srcIn, + ), + ), + const SizedBox(height: 8), + Text( + widget.code.isPinned + ? l10n.unpinText + : l10n.pinText, + ), + ], + ), + padding: const EdgeInsets.only(left: 4, right: 0), + ), + const SizedBox( + width: 14, + ), + SlidableAction( + onPressed: _onEditPressed, + backgroundColor: Colors.grey.withOpacity(0.1), + borderRadius: const BorderRadius.all(Radius.circular(8)), + foregroundColor: + Theme.of(context).colorScheme.inverseBackgroundColor, + icon: Icons.edit_outlined, + label: l10n.edit, + padding: const EdgeInsets.only(left: 4, right: 0), + spacing: 8, + ), + const SizedBox( + width: 14, + ), + SlidableAction( + onPressed: _onDeletePressed, + backgroundColor: Colors.grey.withOpacity(0.1), + borderRadius: const BorderRadius.all(Radius.circular(8)), + foregroundColor: const Color(0xFFFE4A49), + icon: Icons.delete, + label: l10n.delete, + padding: const EdgeInsets.only(left: 0, right: 0), + spacing: 8, + ), + ], ), - const SizedBox( - width: 14, + child: Builder( + builder: (context) => _clippedCard(l10n), ), - SlidableAction( - onPressed: _onEditPressed, - backgroundColor: Colors.grey.withOpacity(0.1), - borderRadius: const BorderRadius.all(Radius.circular(8)), - foregroundColor: - Theme.of(context).colorScheme.inverseBackgroundColor, - icon: Icons.edit_outlined, - label: l10n.edit, - padding: const EdgeInsets.only(left: 4, right: 0), - spacing: 8, - ), - const SizedBox( - width: 14, - ), - SlidableAction( - onPressed: _onDeletePressed, - backgroundColor: Colors.grey.withOpacity(0.1), - borderRadius: const BorderRadius.all(Radius.circular(8)), - foregroundColor: const Color(0xFFFE4A49), - icon: Icons.delete, - label: l10n.delete, - padding: const EdgeInsets.only(left: 0, right: 0), - spacing: 8, - ), - ], - ), - child: Builder( - builder: (context) => _clippedCard(l10n), - ), - ); - }, + ); + }, + ), + ), ), ); } @@ -520,7 +534,6 @@ class _CodeWidgetState extends State { builder: (BuildContext context) { return SetupEnterSecretKeyPage( code: widget.code, - tags: widget.tags, ); }, ), diff --git a/auth/lib/ui/common/gradient_button.dart b/auth/lib/ui/common/gradient_button.dart index 39317e1e2..89a666bc4 100644 --- a/auth/lib/ui/common/gradient_button.dart +++ b/auth/lib/ui/common/gradient_button.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:gradient_borders/box_borders/gradient_box_border.dart'; class GradientButton extends StatefulWidget { @@ -113,22 +114,19 @@ class _GradientButtonState extends State { ], ), ), + if (!isTapped) + ClipRRect( + borderRadius: BorderRadius.circular(widget.borderRadius), + child: SvgPicture.asset( + 'assets/svg/button-tint.svg', + fit: BoxFit.fill, + width: double.infinity, + height: 56, + ), + ), Container( height: 56, decoration: BoxDecoration( - gradient: isTapped - ? null - : const LinearGradient( - begin: Alignment.bottomRight, - end: Alignment.topLeft, - stops: [0, 0.16, 0.88], - colors: [ - Color.fromRGBO(179, 127, 235, 1), - Color.fromRGBO(210, 174, 245, 0), - Color.fromRGBO(239, 219, 255, 1), - ], - ), - backgroundBlendMode: isTapped ? null : BlendMode.hue, border: GradientBoxBorder( width: widget.borderWidth, gradient: const LinearGradient( diff --git a/auth/lib/ui/home_page.dart b/auth/lib/ui/home_page.dart index 2f48b4f6f..1e46890f4 100644 --- a/auth/lib/ui/home_page.dart +++ b/auth/lib/ui/home_page.dart @@ -11,9 +11,12 @@ import 'package:ente_auth/events/trigger_logout_event.dart'; import "package:ente_auth/l10n/l10n.dart"; import 'package:ente_auth/models/code.dart'; import 'package:ente_auth/models/codes.dart'; +import 'package:ente_auth/onboarding/model/tag_enums.dart'; +import 'package:ente_auth/onboarding/view/common/tag_chip.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/user_service.dart'; +import 'package:ente_auth/store/code_display_store.dart'; import 'package:ente_auth/store/code_store.dart'; import 'package:ente_auth/ui/account/logout_dialog.dart'; import 'package:ente_auth/ui/code_error_widget.dart'; @@ -56,8 +59,9 @@ class _HomePageState extends State { final FocusNode searchInputFocusNode = FocusNode(); bool _showSearchBox = false; String _searchText = ""; - Codes? _codes; - List _filteredCodes = []; + AllCodes? _allCodes; + List tags = []; + List _filteredCodes = []; StreamSubscription? _streamSubscription; StreamSubscription? _triggerLogoutEvent; StreamSubscription? _iconsChangedEvent; @@ -99,47 +103,56 @@ class _HomePageState extends State { void _loadCodes() { CodeStore.instance.getAllCodes().then((codes) { - _codes = codes; + _allCodes = codes; _hasLoaded = true; _applyFilteringAndRefresh(); }).onError((error, stackTrace) { _logger.severe('Error while loading codes', error, stackTrace); }); + CodeDisplayStore.instance.getAllTags().then((value) { + tags = value; + + if (mounted) { + if (!tags.contains(selectedTag)) { + selectedTag = ""; + } + setState(() {}); + } + }).onError((error, stackTrace) { + _logger.severe('Error while loading tags', error, stackTrace); + }); } void _applyFilteringAndRefresh() { - if (_searchText.isNotEmpty && _showSearchBox && _codes != null) { + if (_searchText.isNotEmpty && _showSearchBox && _allCodes != null) { final String val = _searchText.toLowerCase(); // Prioritize issuer match above account for better UX while searching // 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 // is the account name. - final List issuerMatch = []; - final List accountMatch = []; - - for (final CodeState codeState in _codes!.allCodes) { - if (codeState.error != null) continue; + final List issuerMatch = []; + final List accountMatch = []; + for (final Code codeState in _allCodes!.codes) { if (selectedTag != "" && - !codeState.code!.display.tags.contains(selectedTag)) { + !codeState.display.tags.contains(selectedTag)) { continue; } - if (codeState.code!.issuer.toLowerCase().contains(val)) { + if (codeState.issuer.toLowerCase().contains(val)) { issuerMatch.add(codeState); - } else if (codeState.code!.account.toLowerCase().contains(val)) { + } else if (codeState.account.toLowerCase().contains(val)) { accountMatch.add(codeState); } } _filteredCodes = issuerMatch; _filteredCodes.addAll(accountMatch); } else { - _filteredCodes = _codes?.allCodes + _filteredCodes = _allCodes?.codes .where( (element) => selectedTag == "" || - element.code != null && - element.code!.display.tags.contains(selectedTag), + element.display.tags.contains(selectedTag), ) .toList() ?? []; @@ -169,7 +182,7 @@ class _HomePageState extends State { if (code != null) { await CodeStore.instance.addCode(code); // Focus the new code by searching - if ((_codes?.allCodes.length ?? 0) > 2) { + if ((_allCodes?.codes.length ?? 0) > 2) { _focusNewCode(code); } } @@ -179,9 +192,7 @@ class _HomePageState extends State { final Code? code = await Navigator.of(context).push( MaterialPageRoute( builder: (BuildContext context) { - return SetupEnterSecretKeyPage( - tags: _codes?.tags ?? [], - ); + return SetupEnterSecretKeyPage(); }, ), ); @@ -193,10 +204,7 @@ class _HomePageState extends State { @override Widget build(BuildContext context) { final l10n = context.l10n; - if (!(_codes?.tags.contains(selectedTag) ?? true)) { - selectedTag = ""; - setState(() {}); - } + return PopScope( onPopInvoked: (_) async { if (_isSettingsOpen) { @@ -245,30 +253,32 @@ class _HomePageState extends State { ), centerTitle: true, actions: [ - IconButton( - icon: _showSearchBox - ? const Icon(Icons.clear) - : const Icon(Icons.search), - tooltip: l10n.search, - onPressed: () { - setState( - () { - _showSearchBox = !_showSearchBox; - if (!_showSearchBox) { - _textController.clear(); - _searchText = ""; - } else { - _searchText = _textController.text; - } - _applyFilteringAndRefresh(); - }, - ); - }, - ), + if (_allCodes?.state == AllCodesState.value) + IconButton( + icon: _showSearchBox + ? const Icon(Icons.clear) + : const Icon(Icons.search), + tooltip: l10n.search, + onPressed: () { + setState( + () { + _showSearchBox = !_showSearchBox; + if (!_showSearchBox) { + _textController.clear(); + _searchText = ""; + } else { + _searchText = _textController.text; + } + _applyFilteringAndRefresh(); + }, + ); + }, + ), ], ), floatingActionButton: !_hasLoaded || - (_codes?.allCodes.isEmpty ?? true) || + (_allCodes?.codes.isEmpty ?? true) || + _allCodes?.state == AllCodesState.error || !PreferenceService.instance.hasShownCoachMark() ? null : _getFab(), @@ -288,71 +298,75 @@ class _HomePageState extends State { 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) { + if (_allCodes?.state == AllCodesState.value) + 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: tags.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + return TagChip( + label: "All", + state: selectedTag == "" + ? TagChipState.selected + : TagChipState.unselected, + onTap: () { + selectedTag = ""; + setState(() {}); + _applyFilteringAndRefresh(); + }, + ); + } return TagChip( - label: "All", - state: selectedTag == "" + label: tags[index - 1], + action: TagChipAction.menu, + state: selectedTag == tags[index - 1] ? TagChipState.selected : TagChipState.unselected, onTap: () { - selectedTag = ""; + if (selectedTag == tags[index - 1]) { + selectedTag = ""; + setState(() {}); + _applyFilteringAndRefresh(); + return; + } + selectedTag = tags[index - 1]; setState(() {}); _applyFilteringAndRefresh(); }, ); - } - return TagChip( - label: _codes!.tags[index - 1], - action: TagChipAction.menu, - state: selectedTag == _codes!.tags[index - 1] - ? TagChipState.selected - : TagChipState.unselected, - onTap: () { - if (selectedTag == _codes!.tags[index - 1]) { - selectedTag = ""; - setState(() {}); - _applyFilteringAndRefresh(); - return; - } - selectedTag = _codes!.tags[index - 1]; - setState(() {}); - _applyFilteringAndRefresh(); - }, - ); - }, + }, + ), ), - ), Expanded( child: AlignedGridView.count( crossAxisCount: (MediaQuery.sizeOf(context).width ~/ 400) .clamp(1, double.infinity) .toInt(), + physics: _allCodes?.state == AllCodesState.value + ? const AlwaysScrollableScrollPhysics() + : const NeverScrollableScrollPhysics(), padding: const EdgeInsets.only(bottom: 80), itemBuilder: ((context, index) { - try { - if (_filteredCodes[index].error != null) { - return const CodeErrorWidget(); - } - return ClipRect( - child: CodeWidget( - _filteredCodes[index].code!, - _codes?.tags ?? [], - ), - ); - } catch (e) { - return const Text("Failed"); + if (index == 0 && _allCodes?.state == AllCodesState.error) { + return const CodeErrorWidget(); } + final newIndex = + index - (_allCodes?.state == AllCodesState.error ? 1 : 0); + return ClipRect( + child: CodeWidget( + _filteredCodes[newIndex], + hasError: _allCodes?.state == AllCodesState.error, + ), + ); }), - itemCount: _filteredCodes.length, + itemCount: (_allCodes?.state == AllCodesState.error ? 1 : 0) + + _filteredCodes.length, ), ), ], @@ -377,18 +391,9 @@ class _HomePageState extends State { padding: const EdgeInsets.only(bottom: 80), itemBuilder: ((context, index) { final codeState = _filteredCodes[index]; - if (codeState.code != null) { - return CodeWidget( - codeState.code!, - _codes?.tags ?? [], - ); - } else { - _logger.severe( - "code widget error", - codeState.error, - ); - return const CodeErrorWidget(); - } + return CodeWidget( + codeState, + ); }), itemCount: _filteredCodes.length, ) diff --git a/auth/lib/ui/settings/data/export_widget.dart b/auth/lib/ui/settings/data/export_widget.dart index dcaf15afa..e7372f604 100644 --- a/auth/lib/ui/settings/data/export_widget.dart +++ b/auth/lib/ui/settings/data/export_widget.dart @@ -171,9 +171,9 @@ Future _exportCodes(BuildContext context, String fileContent) async { } Future _getAuthDataForExport() async { - final codes = await CodeStore.instance.getAllCodes(); + final allCodes = await CodeStore.instance.getAllCodes(); String data = ""; - for (final code in codes.validCodes) { + for (final code in allCodes.codes) { data += "${code.rawData}\n"; } diff --git a/auth/lib/ui/settings_page.dart b/auth/lib/ui/settings_page.dart index 087b9f9ea..512322753 100644 --- a/auth/lib/ui/settings_page.dart +++ b/auth/lib/ui/settings_page.dart @@ -108,9 +108,8 @@ class SettingsPage extends StatelessWidget { await handleExportClick(context); } else { if (result.action == ButtonAction.second) { - bool hasCodes = (await CodeStore.instance.getAllCodes()) - .validCodes - .isNotEmpty; + bool hasCodes = + (await CodeStore.instance.getAllCodes()).codes.isNotEmpty; if (hasCodes) { final hasAuthenticated = await LocalAuthenticationService .instance