diff --git a/auth/lib/l10n/arb/app_en.arb b/auth/lib/l10n/arb/app_en.arb index 9873990a6..24b298e3d 100644 --- a/auth/lib/l10n/arb/app_en.arb +++ b/auth/lib/l10n/arb/app_en.arb @@ -427,5 +427,12 @@ "pinText": "Pin", "unpinText": "Unpin", "pinnedCodeMessage": "{code} has been pinned", - "unpinnedCodeMessage": "{code} has been unpinned" + "unpinnedCodeMessage": "{code} has been unpinned", + "tags": "Tags", + "createNewTag": "Create New Tag", + "tag": "Tag", + "create": "Create", + "editTag": "Edit Tag", + "deleteTagTitle": "Delete tag?", + "deleteTagMessage": "Are you sure you want to delete this tag? This action is irreversible." } \ No newline at end of file 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 6f53f565d..97b61d1c1 100644 --- a/auth/lib/onboarding/view/setup_enter_secret_key_page.dart +++ b/auth/lib/onboarding/view/setup_enter_secret_key_page.dart @@ -6,6 +6,7 @@ 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; @@ -22,9 +23,9 @@ class _SetupEnterSecretKeyPageState extends State { late TextEditingController _issuerController; late TextEditingController _accountController; late TextEditingController _secretController; - late TextEditingController _tagController; late bool _secretKeyObscured; late List tags = [...?widget.code?.display.tags]; + late List allTags = [...widget.tags]; @override void initState() { @@ -38,7 +39,6 @@ class _SetupEnterSecretKeyPageState extends State { _secretController = TextEditingController( text: widget.code?.secret, ); - _tagController = TextEditingController(); _secretKeyObscured = widget.code != null; super.initState(); } @@ -117,51 +117,59 @@ class _SetupEnterSecretKeyPageState extends State { 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, + Text( + l10n.tags, + style: const TextStyle( + fontWeight: FontWeight.bold, ), - 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(height: 10), + Wrap( + spacing: 12, + alignment: WrapAlignment.start, + children: [ + ...allTags.map( + (e) => TagChip( + label: e, + action: TagChipAction.check, + state: tags.contains(e) + ? TagChipState.selected + : TagChipState.unselected, + onTap: () { + if (tags.contains(e)) { + tags.remove(e); + } else { + tags.add(e); + } + setState(() {}); + }, + ), + ), + AddChip( + onTap: () { + showDialog( + context: context, + builder: (BuildContext context) { + return AddTagDialog( + onTap: (tag) { + if (allTags.contains(tag) && + tags.contains(tag)) { + return; + } + allTags.add(tag); + tags.add(tag); + setState(() {}); + Navigator.pop(context); + }, + ); + }, + barrierColor: Colors.black.withOpacity(0.85), + barrierDismissible: false, + ); + }, + ), + ], + ), const SizedBox( height: 40, ), @@ -187,7 +195,6 @@ class _SetupEnterSecretKeyPageState extends State { child: Padding( padding: const EdgeInsets.symmetric( horizontal: 16.0, - vertical: 4, ), child: Text(l10n.saveAction), ), @@ -254,15 +261,11 @@ class _SetupEnterSecretKeyPageState extends State { } } -class TagChip extends StatelessWidget { - final String label; +class AddChip extends StatelessWidget { final VoidCallback? onTap; - final bool isSelected; - const TagChip({ + const AddChip({ super.key, - required this.label, - this.isSelected = false, this.onTap, }); @@ -270,35 +273,336 @@ class TagChip extends StatelessWidget { Widget build(BuildContext context) { return GestureDetector( onTap: onTap, - child: Chip( - label: Row( + 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 + ? Theme.of(context).brightness == Brightness.dark + ? const Color(0xFF722ED1) + : 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(0x00B37FEB), + const Color(0x33AE40E3), + ] + : [ + Theme.of(context).brightness == Brightness.dark + ? const Color(0xFFAD00FF) + : const Color(0x00AD00FF), + Theme.of(context).brightness == Brightness.dark + ? const Color(0x43A269BD) + : const Color(0x338609C2), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + ), + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 16), + child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( label, style: TextStyle( - color: isSelected ? Colors.white : const Color(0xFF8232E1), + color: state == TagChipState.selected || + Theme.of(context).brightness == Brightness.dark + ? Colors.white + : const Color(0xFF8232E1), ), ), - if (isSelected) ...[ + if (state == TagChipState.selected && + action == TagChipAction.check) ...[ const SizedBox(width: 4), const Icon( Icons.check, size: 16, color: Colors.white, ), + ] else if (state == TagChipState.selected && + action == TagChipAction.menu) ...[ + SizedBox( + width: 18, + child: PopupMenuButton( + iconSize: 16, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + surfaceTintColor: Theme.of(context).cardColor, + iconColor: Colors.white, + initialValue: -1, + padding: EdgeInsets.zero, + onSelected: (value) { + if (value == 0) { + showEditDialog(context, label); + } else if (value == 1) { + showDeleteTagDialog(context); + } + }, + 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, + ), + ]; + }, + ), + ), ], ], ), - backgroundColor: - isSelected ? const Color(0xFF722ED1) : const Color(0xFFFCF5FF), - side: BorderSide( - color: isSelected ? const Color(0xFF722ED1) : const Color(0xFFFCF5FF), - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(100), - ), ), ); } } + +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: () { + if (_tag.trim().isEmpty) return; + + // traverse through all the codes and edit this tag's value + }, + ), + ], + ); + } +} + +Future showDeleteTagDialog(BuildContext context) 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 codes and remove this tag + }, + ); +} + +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/theme/colors.dart b/auth/lib/theme/colors.dart index 9ac9d2d7e..c0c5eeb09 100644 --- a/auth/lib/theme/colors.dart +++ b/auth/lib/theme/colors.dart @@ -200,7 +200,7 @@ const Color _primary500 = Color.fromARGB(255, 204, 10, 101); const Color _primary400 = Color.fromARGB(255, 122, 41, 193); const Color _primary300 = Color.fromARGB(255, 152, 77, 244); -const Color _warning700 = Color.fromRGBO(234, 63, 63, 1); +const Color _warning700 = Color.fromRGBO(245, 52, 52, 1); const Color _warning500 = Color.fromRGBO(255, 101, 101, 1); const Color _warning800 = Color(0xFFF53434); const Color warning500 = Color.fromRGBO(255, 101, 101, 1); diff --git a/auth/lib/ui/code_widget.dart b/auth/lib/ui/code_widget.dart index 8c84fe2a2..3504da333 100644 --- a/auth/lib/ui/code_widget.dart +++ b/auth/lib/ui/code_widget.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:ui' as ui; import 'package:clipboard/clipboard.dart'; import 'package:ente_auth/core/configuration.dart'; @@ -159,9 +160,21 @@ class _CodeWidgetState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ if (widget.code.isPinned) - SvgPicture.asset("assets/svg/pin-active.svg") + 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"), + 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, @@ -276,7 +289,11 @@ class _CodeWidgetState extends State { Align( alignment: Alignment.topRight, child: CustomPaint( - painter: PinBgPainter(strokeColor: const Color(0xFFF9ECFF)), + painter: PinBgPainter( + color: Theme.of(context).brightness == Brightness.dark + ? const Color(0xFF390C4F) + : const Color(0xFFF9ECFF), + ), size: const Size(39, 39), ), ), @@ -600,21 +617,18 @@ class _CodeWidgetState extends State { } class PinBgPainter extends CustomPainter { - final Color strokeColor; + final Color color; final PaintingStyle paintingStyle; - final double strokeWidth; PinBgPainter({ - this.strokeColor = Colors.black, - this.strokeWidth = 3, + this.color = Colors.black, this.paintingStyle = PaintingStyle.fill, }); @override void paint(Canvas canvas, Size size) { Paint paint = Paint() - ..color = strokeColor - ..strokeWidth = strokeWidth + ..color = color ..style = paintingStyle; canvas.drawPath(getTrianglePath(size.width, size.height), paint); @@ -630,8 +644,7 @@ class PinBgPainter extends CustomPainter { @override bool shouldRepaint(PinBgPainter oldDelegate) { - return oldDelegate.strokeColor != strokeColor || - oldDelegate.paintingStyle != paintingStyle || - oldDelegate.strokeWidth != strokeWidth; + return oldDelegate.color != color || + oldDelegate.paintingStyle != paintingStyle; } } diff --git a/auth/lib/ui/home_page.dart b/auth/lib/ui/home_page.dart index 401982b98..640de323a 100644 --- a/auth/lib/ui/home_page.dart +++ b/auth/lib/ui/home_page.dart @@ -60,6 +60,7 @@ class _HomePageState extends State { StreamSubscription? _streamSubscription; StreamSubscription? _triggerLogoutEvent; StreamSubscription? _iconsChangedEvent; + String selectedTag = ""; @override void initState() { @@ -118,6 +119,11 @@ class _HomePageState extends State { for (final CodeState codeState in _codes!.allCodes) { if (codeState.error != null) continue; + if (selectedTag != "" && + !codeState.code!.display.tags.contains(selectedTag)) { + continue; + } + if (codeState.code!.issuer.toLowerCase().contains(val)) { issuerMatch.add(codeState); } else if (codeState.code!.account.toLowerCase().contains(val)) { @@ -127,7 +133,15 @@ class _HomePageState extends State { _filteredCodes = issuerMatch; _filteredCodes.addAll(accountMatch); } else { - _filteredCodes = _codes?.allCodes ?? []; + _filteredCodes = _codes?.allCodes + .where( + (element) => + selectedTag == "" || + element.code != null && + element.code!.display.tags.contains(selectedTag), + ) + .toList() ?? + []; } if (mounted) { setState(() {}); @@ -178,6 +192,10 @@ 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) { @@ -279,12 +297,30 @@ class _HomePageState extends State { itemCount: _codes?.tags == null ? 0 : _codes!.tags.length + 1, itemBuilder: (context, index) { if (index == 0) { - return const TagChip( + return TagChip( label: "All", - isSelected: true, + state: selectedTag == "" + ? TagChipState.selected + : TagChipState.unselected, + onTap: () { + selectedTag = ""; + setState(() {}); + _applyFilteringAndRefresh(); + }, ); } - return TagChip(label: _codes!.tags[index - 1]); + return TagChip( + label: _codes!.tags[index - 1], + action: TagChipAction.menu, + state: selectedTag == _codes!.tags[index - 1] + ? TagChipState.selected + : TagChipState.unselected, + onTap: () { + selectedTag = _codes!.tags[index - 1]; + setState(() {}); + _applyFilteringAndRefresh(); + }, + ); }, ), ), diff --git a/auth/pubspec.lock b/auth/pubspec.lock index 2d61b77c3..8db6c9a80 100644 --- a/auth/pubspec.lock +++ b/auth/pubspec.lock @@ -721,6 +721,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.6" + gradient_borders: + dependency: "direct main" + description: + name: gradient_borders + sha256: "69eeaff519d145a4c6c213ada1abae386bcc8981a4970d923e478ce7ba19e309" + url: "https://pub.dev" + source: hosted + version: "1.0.0" graphs: dependency: transitive description: diff --git a/auth/pubspec.yaml b/auth/pubspec.yaml index 0852e96cb..ac27de470 100644 --- a/auth/pubspec.yaml +++ b/auth/pubspec.yaml @@ -61,6 +61,7 @@ dependencies: flutter_svg: ^2.0.5 fluttertoast: ^8.1.1 google_nav_bar: ^5.0.5 #supported + gradient_borders: ^1.0.0 http: ^1.1.0 intl: ^0.18.0 json_annotation: ^4.5.0