From d751c1a345f7a492572f6c56ee4c46dca091e9ac Mon Sep 17 00:00:00 2001 From: Dain Nilsson Date: Wed, 2 Mar 2022 08:23:29 +0100 Subject: [PATCH 1/8] Make OATH accounts selectable. --- lib/oath/state.dart | 46 +++++ lib/oath/views/account_dialog.dart | 170 ++++++++++++++++ lib/oath/views/account_view.dart | 236 +++++----------------- lib/oath/views/delete_account_dialog.dart | 6 +- lib/oath/views/utils.dart | 55 +++++ lib/theme.dart | 6 + 6 files changed, 327 insertions(+), 192 deletions(-) create mode 100755 lib/oath/views/account_dialog.dart diff --git a/lib/oath/state.dart b/lib/oath/state.dart index 917cd63f..2ad0afb4 100755 --- a/lib/oath/state.dart +++ b/lib/oath/state.dart @@ -50,6 +50,52 @@ abstract class OathCredentialListNotifier Future deleteAccount(OathCredential credential); } +final credentialsProvider = Provider.autoDispose?>((ref) { + final node = ref.watch(currentDeviceProvider); + if (node != null) { + return ref.watch(credentialListProvider(node.path) + .select((pairs) => pairs?.map((e) => e.credential).toList())); + } + return null; +}); + +final codeProvider = + Provider.autoDispose.family((ref, credential) { + final node = ref.watch(currentDeviceProvider); + if (node != null) { + return ref + .watch(credentialListProvider(node.path).select((pairs) => + pairs?.firstWhere((pair) => pair.credential == credential))) + ?.code; + } + return null; +}); + +final expiredProvider = + StateNotifierProvider.autoDispose.family<_ExpireNotifier, bool, int>( + (ref, expiry) => + _ExpireNotifier(DateTime.now().millisecondsSinceEpoch, expiry * 1000), +); + +class _ExpireNotifier extends StateNotifier { + Timer? _timer; + _ExpireNotifier(int now, int expiry) : super(expiry <= now) { + if (expiry > now) { + _timer = Timer(Duration(milliseconds: expiry - now), () { + if (mounted) { + state = true; + } + }); + } + } + + @override + dispose() { + _timer?.cancel(); + super.dispose(); + } +} + final favoritesProvider = StateNotifierProvider>( (ref) => FavoritesNotifier(ref.watch(prefProvider))); diff --git a/lib/oath/views/account_dialog.dart b/lib/oath/views/account_dialog.dart new file mode 100755 index 00000000..849bb2fc --- /dev/null +++ b/lib/oath/views/account_dialog.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app/models.dart'; +import '../../widgets/circle_timer.dart'; +import '../models.dart'; +import '../state.dart'; +import 'delete_account_dialog.dart'; +import 'rename_account_dialog.dart'; +import 'utils.dart'; + +class AccountDialog extends ConsumerWidget { + final DeviceNode device; + final OathCredential credential; + const AccountDialog(this.device, this.credential, {Key? key}) + : super(key: key); + + List _buildActions(BuildContext context, WidgetRef ref, + OathCode? code, bool expired, bool favorite) { + final manual = + credential.touchRequired || credential.oathType == OathType.hotp; + final ready = expired || credential.oathType == OathType.hotp; + + return [ + if (manual) + IconButton( + icon: const Icon(Icons.refresh), + tooltip: 'Calculate', + onPressed: ready + ? () { + calculateCode( + context, + credential, + ref.read(credentialListProvider(device.path).notifier), + ); + } + : null, + ), + IconButton( + icon: const Icon(Icons.copy), + tooltip: 'Copy to clipboard', + onPressed: code == null || expired + ? null + : () { + Clipboard.setData(ClipboardData(text: code.value)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Code copied to clipboard'), + duration: Duration(seconds: 2), + ), + ); + }, + ), + IconButton( + icon: Icon(favorite ? Icons.star : Icons.star_border), + tooltip: favorite ? 'Remove from favorites' : 'Add to favorites', + onPressed: () { + ref.read(favoritesProvider.notifier).toggleFavorite(credential.id); + }, + ), + IconButton( + icon: const Icon(Icons.edit), + tooltip: 'Rename account', + onPressed: () { + showDialog( + context: context, + builder: (context) => RenameAccountDialog(device, credential), + ); + }, + ), + IconButton( + icon: const Icon(Icons.delete_forever), + tooltip: 'Delete account', + onPressed: () async { + final result = await showDialog( + context: context, + builder: (context) => DeleteAccountDialog(device, credential), + ); + if (result) { + Navigator.of(context).pop(); + } + }, + ), + ]; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final label = credential.issuer != null + ? '${credential.issuer} (${credential.name})' + : credential.name; + + final code = ref.watch(codeProvider(credential)); + final expired = code == null || + (credential.oathType == OathType.totp && + ref.watch(expiredProvider(code.validTo))); + final favorite = ref.watch(favoritesProvider).contains(credential.id); + + return Dialog( + backgroundColor: Colors.transparent, + elevation: 0.0, + insetPadding: const EdgeInsets.all(0), + child: Scaffold( + backgroundColor: Colors.transparent, + appBar: AppBar( + actions: _buildActions(context, ref, code, expired, favorite), + ), + body: LayoutBuilder(builder: (context, constraints) { + return ListView( + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + Navigator.of(context).pop(); + }, + child: Container( + alignment: Alignment.center, + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + padding: const EdgeInsets.all(20.0), + child: GestureDetector( + onTap: () {}, // Blocks parent detector GestureDetector + child: Material( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20.0)), + elevation: 16.0, + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: Column( + children: [ + Text( + formatOathCode(code), + style: expired + ? Theme.of(context) + .textTheme + .headline2 + ?.copyWith(color: Colors.grey) + : Theme.of(context).textTheme.headline2, + ), + Text(label), + Padding( + padding: const EdgeInsets.all(8.0), + child: SizedBox.square( + dimension: 16, + child: code != null + ? CircleTimer( + code.validFrom * 1000, + code.validTo * 1000, + ) + : null, + ), + ) + ], + ), + ), + ), + ), + ), + ), + ), + ], + ); + }), + ), + ); + } +} diff --git a/lib/oath/views/account_view.dart b/lib/oath/views/account_view.dart index 15513c25..4ceb329c 100755 --- a/lib/oath/views/account_view.dart +++ b/lib/oath/views/account_view.dart @@ -3,41 +3,13 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:yubico_authenticator/oath/views/account_dialog.dart'; import '../../widgets/circle_timer.dart'; import '../../app/models.dart'; import '../models.dart'; import '../state.dart'; -import 'delete_account_dialog.dart'; -import 'rename_account_dialog.dart'; - -final _expireProvider = - StateNotifierProvider.autoDispose.family<_ExpireNotifier, bool, int>( - (ref, expiry) => - _ExpireNotifier(DateTime.now().millisecondsSinceEpoch, expiry * 1000), -); - -class _ExpireNotifier extends StateNotifier { - Timer? _timer; - _ExpireNotifier(int now, int expiry) : super(expiry <= now) { - if (expiry > now) { - _timer = Timer(Duration(milliseconds: expiry - now), () { - if (mounted) { - state = true; - } - }); - } - } - - @override - dispose() { - _timer?.cancel(); - super.dispose(); - } -} - -// TODO: Replace this with something cleaner -final _busyCalculatingProvider = StateProvider((ref) => false); +import 'utils.dart'; class AccountView extends ConsumerWidget { final YubiKeyData deviceData; @@ -48,125 +20,28 @@ class AccountView extends ConsumerWidget { {Key? key, this.focusNode}) : super(key: key); - String formatCode() { - var value = code?.value; - if (value == null) { - return '••• •••'; - } else if (value.length < 6) { - return value; - } else { - var i = value.length ~/ 2; - return value.substring(0, i) + ' ' + value.substring(i); - } - } - - List _buildPopupMenu( - BuildContext context, WidgetRef ref, bool trigger) => - [ - PopupMenuItem( - child: const ListTile( - leading: Icon(Icons.copy), - title: Text('Copy to clipboard'), - ), - onTap: () { - _copyToClipboard(context, ref, trigger); - }, - ), - PopupMenuItem( - child: const ListTile( - leading: Icon(Icons.star), - title: Text('Toggle favorite'), - ), - onTap: () { - ref.read(favoritesProvider.notifier).toggleFavorite(credential.id); - }, - ), - if (deviceData.info.version.major >= 5 && - deviceData.info.version.minor >= 3) - PopupMenuItem( - child: const ListTile( - leading: Icon(Icons.edit), - title: Text('Rename account'), - ), - onTap: () { - // This ensures the onTap handler finishes before the dialog is shown, otherwise the dialog is immediately closed instead of the popup. - Future.delayed(Duration.zero, () { - showDialog( - context: context, - builder: (context) => - RenameAccountDialog(deviceData.node, credential), - ); - }); - }, - ), - const PopupMenuDivider(), - PopupMenuItem( - child: const ListTile( - leading: Icon(Icons.delete_forever), - title: Text('Delete account'), - ), - onTap: () { - // This ensures the onTap handler finishes before the dialog is shown, otherwise the dialog is immediately closed instead of the popup. - Future.delayed(Duration.zero, () { - showDialog( - context: context, - builder: (context) => - DeleteAccountDialog(deviceData.node, credential), - ); - }); - }, - ), - ]; - _copyToClipboard(BuildContext context, WidgetRef ref, bool trigger) async { - final busy = ref.read(_busyCalculatingProvider.notifier); - if (busy.state) return; - final scaffoldMessenger = ScaffoldMessenger.of(context); - try { - busy.state = true; - String value; - if (trigger) { - final updated = await _calculate(context, ref); - value = updated.value; - } else { - value = code!.value; - } - await Clipboard.setData(ClipboardData(text: value)); - - await scaffoldMessenger - .showSnackBar( - const SnackBar( - content: Text('Code copied to clipboard'), - duration: Duration(seconds: 2), - ), - ) - .closed; - } finally { - busy.state = false; - } - } - - Future _calculate(BuildContext context, WidgetRef ref) async { - Function? close; - if (credential.touchRequired) { - close = ScaffoldMessenger.of(context) - .showSnackBar( - const SnackBar( - content: Text('Touch your YubiKey'), - duration: Duration(seconds: 30), - ), - ) - .close; - } - try { - return await ref - .read(credentialListProvider(deviceData.node.path).notifier) - .calculate(credential); - } finally { - // Hide the touch prompt when done - close?.call(); + String value; + if (trigger) { + final updated = await calculateCode( + context, + credential, + ref.read(credentialListProvider(deviceData.node.path).notifier), + ); + value = updated.value; + } else { + value = code!.value; } + await Clipboard.setData(ClipboardData(text: value)); + await scaffoldMessenger + .showSnackBar( + const SnackBar( + content: Text('Code copied to clipboard'), + duration: Duration(seconds: 2), + ), + ) + .closed; } Color _iconColor(String label, int shade) { @@ -200,26 +75,27 @@ class AccountView extends ConsumerWidget { final label = credential.issuer != null ? '${credential.issuer} (${credential.name})' : credential.name; - final expireAt = credential.oathType == OathType.hotp - ? (code?.validFrom ?? 0) + - 30 // HOTP codes valid for 30s from generation - : code?.validTo ?? 0; - final expired = ref.watch(_expireProvider(expireAt)); + final expired = code == null || + (credential.oathType == OathType.totp && + ref.watch(expiredProvider(code.validTo))); final trigger = code == null || - expired && - (credential.touchRequired || credential.oathType == OathType.hotp); + credential.oathType == OathType.hotp || + (credential.touchRequired && expired); final darkMode = Theme.of(context).brightness == Brightness.dark; return ListTile( focusNode: focusNode, onTap: () { - final focus = focusNode; - if (focus != null && focus.hasFocus == false) { - focus.requestFocus(); - } else { - _copyToClipboard(context, ref, trigger); - } + showDialog( + context: context, + builder: (context) { + return AccountDialog(deviceData.node, credential); + }, + ); + }, + onLongPress: () { + _copyToClipboard(context, ref, trigger); }, leading: CircleAvatar( foregroundColor: darkMode ? Colors.black : Colors.white, @@ -230,7 +106,7 @@ class AccountView extends ConsumerWidget { ), ), title: Text( - formatCode(), + formatOathCode(code), style: expired ? Theme.of(context) .textTheme @@ -245,36 +121,18 @@ class AccountView extends ConsumerWidget { maxLines: 1, softWrap: false, ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Column( - children: [ - Align( - alignment: AlignmentDirectional.topCenter, - child: trigger - ? const Icon( - Icons.touch_app, - size: 18, - ) - : SizedBox.square( - dimension: 16, - child: CircleTimer( - code.validFrom * 1000, - code.validTo * 1000, - ), - ), + trailing: trigger + ? Icon( + credential.touchRequired ? Icons.touch_app : Icons.refresh, + size: 18, + ) + : SizedBox.square( + dimension: 16, + child: CircleTimer( + code.validFrom * 1000, + code.validTo * 1000, ), - const Spacer(), - PopupMenuButton( - child: Icon(Icons.adaptive.more), - itemBuilder: (context) => - _buildPopupMenu(context, ref, trigger), - ), - ], - ), - ], - ), + ), ); } } diff --git a/lib/oath/views/delete_account_dialog.dart b/lib/oath/views/delete_account_dialog.dart index 2b8dd349..2581063c 100755 --- a/lib/oath/views/delete_account_dialog.dart +++ b/lib/oath/views/delete_account_dialog.dart @@ -16,7 +16,7 @@ class DeleteAccountDialog extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { // If current device changes, we need to pop back to the main Page. ref.listen(currentDeviceProvider, (previous, next) { - Navigator.of(context).pop(); + Navigator.of(context).pop(false); }); final label = credential.issuer != null @@ -40,7 +40,7 @@ class DeleteAccountDialog extends ConsumerWidget { actions: [ OutlinedButton( onPressed: () { - Navigator.of(context).pop(); + Navigator.of(context).pop(false); }, child: const Text('Cancel'), ), @@ -49,7 +49,7 @@ class DeleteAccountDialog extends ConsumerWidget { await ref .read(credentialListProvider(device.path).notifier) .deleteAccount(credential); - Navigator.of(context).pop(); + Navigator.of(context).pop(true); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Account deleted'), diff --git a/lib/oath/views/utils.dart b/lib/oath/views/utils.dart index 96cb3b09..5931984c 100755 --- a/lib/oath/views/utils.dart +++ b/lib/oath/views/utils.dart @@ -1,7 +1,11 @@ +import 'dart:async'; import 'dart:math'; +import 'package:flutter/material.dart'; + import '../models.dart'; import '../../core/models.dart'; +import '../state.dart'; /// Calculates the available space for issuer and account name. /// @@ -30,3 +34,54 @@ Pair getRemainingKeySpace( remaining - issuerSpace, ); } + +/// Formats an OATH code for display. +/// +/// If the [OathCode] is null, then a placeholder string is returned. +String formatOathCode(OathCode? code) { + var value = code?.value; + if (value == null) { + return '••• •••'; + } else if (value.length < 6) { + return value; + } else { + var i = value.length ~/ 2; + return value.substring(0, i) + ' ' + value.substring(i); + } +} + +/// Calculates a new OATH code for a credential. +/// +/// This function will take care of prompting the user for touch if needed. +Future calculateCode(BuildContext context, OathCredential credential, + OathCredentialListNotifier notifier) async { + Function? close; + if (credential.touchRequired) { + close = ScaffoldMessenger.of(context) + .showSnackBar( + const SnackBar( + content: Text('Touch your YubiKey'), + duration: Duration(seconds: 30), + ), + ) + .close; + } else if (credential.oathType == OathType.hotp) { + final showPrompt = Timer(const Duration(milliseconds: 500), () { + close = ScaffoldMessenger.of(context) + .showSnackBar( + const SnackBar( + content: Text('Touch your YubiKey'), + duration: Duration(seconds: 30), + ), + ) + .close; + }); + close = showPrompt.cancel; + } + try { + return await notifier.calculate(credential); + } finally { + // Hide the touch prompt when done + close?.call(); + } +} diff --git a/lib/theme.dart b/lib/theme.dart index 2a48d2f9..73d0a579 100755 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -27,6 +27,9 @@ class AppTheme { bodyText2: TextStyle( color: Colors.grey.shade800, ), + headline2: TextStyle( + color: Colors.grey.shade800, + ), ), ); @@ -45,6 +48,9 @@ class AppTheme { bodyText2: TextStyle( color: Colors.grey.shade500, ), + headline2: TextStyle( + color: Colors.grey.shade100, + ), ), ); } From 5e9cd7d0d485c4025775ac6d1941053066451bb1 Mon Sep 17 00:00:00 2001 From: Dain Nilsson Date: Wed, 2 Mar 2022 10:44:35 +0100 Subject: [PATCH 2/8] Blur background and set max width. --- lib/oath/views/account_dialog.dart | 130 ++++++++++++++++------------- lib/oath/views/account_view.dart | 2 - 2 files changed, 71 insertions(+), 61 deletions(-) diff --git a/lib/oath/views/account_dialog.dart b/lib/oath/views/account_dialog.dart index 849bb2fc..568e3e76 100755 --- a/lib/oath/views/account_dialog.dart +++ b/lib/oath/views/account_dialog.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -97,73 +99,83 @@ class AccountDialog extends ConsumerWidget { ref.watch(expiredProvider(code.validTo))); final favorite = ref.watch(favoritesProvider).contains(credential.id); - return Dialog( - backgroundColor: Colors.transparent, - elevation: 0.0, - insetPadding: const EdgeInsets.all(0), - child: Scaffold( + return BackdropFilter( + filter: ImageFilter.blur(sigmaX: 3, sigmaY: 3), + child: Dialog( backgroundColor: Colors.transparent, - appBar: AppBar( - actions: _buildActions(context, ref, code, expired, favorite), - ), - body: LayoutBuilder(builder: (context, constraints) { - return ListView( - children: [ - GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () { - Navigator.of(context).pop(); - }, - child: Container( - alignment: Alignment.center, - constraints: BoxConstraints( - minHeight: constraints.maxHeight, - ), - padding: const EdgeInsets.all(20.0), - child: GestureDetector( - onTap: () {}, // Blocks parent detector GestureDetector - child: Material( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20.0)), - elevation: 16.0, - child: Center( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12.0), - child: Column( - children: [ - Text( - formatOathCode(code), - style: expired - ? Theme.of(context) - .textTheme - .headline2 - ?.copyWith(color: Colors.grey) - : Theme.of(context).textTheme.headline2, - ), - Text(label), - Padding( - padding: const EdgeInsets.all(8.0), - child: SizedBox.square( - dimension: 16, - child: code != null - ? CircleTimer( - code.validFrom * 1000, - code.validTo * 1000, - ) - : null, + elevation: 0.0, + insetPadding: const EdgeInsets.all(0), + child: Scaffold( + backgroundColor: Colors.transparent, + appBar: AppBar( + actions: _buildActions(context, ref, code, expired, favorite), + ), + body: LayoutBuilder(builder: (context, constraints) { + return ListView( + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + Navigator.of(context).pop(); + }, + child: Container( + alignment: Alignment.center, + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + padding: const EdgeInsets.all(20.0), + child: GestureDetector( + onTap: () {}, // Blocks parent detector GestureDetector + child: Material( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20.0)), + elevation: 16.0, + child: SizedBox( + width: 320, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: Column( + children: [ + Text( + formatOathCode(code), + softWrap: false, + style: expired + ? Theme.of(context) + .textTheme + .headline2 + ?.copyWith(color: Colors.grey) + : Theme.of(context).textTheme.headline2, ), - ) - ], + Text( + label, + overflow: TextOverflow.fade, + maxLines: 1, + softWrap: false, + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: SizedBox.square( + dimension: 16, + child: code != null + ? CircleTimer( + code.validFrom * 1000, + code.validTo * 1000, + ) + : null, + ), + ) + ], + ), ), ), ), ), ), ), - ), - ], - ); - }), + ], + ); + }), + ), ), ); } diff --git a/lib/oath/views/account_view.dart b/lib/oath/views/account_view.dart index 4ceb329c..5df22108 100755 --- a/lib/oath/views/account_view.dart +++ b/lib/oath/views/account_view.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; From af27c5ef0f73e34a54c435b7eb79372b2949b630 Mon Sep 17 00:00:00 2001 From: Dain Nilsson Date: Wed, 2 Mar 2022 11:08:07 +0100 Subject: [PATCH 3/8] Better handling of window resizing. --- lib/oath/views/account_dialog.dart | 31 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/lib/oath/views/account_dialog.dart b/lib/oath/views/account_dialog.dart index 568e3e76..a68fb5fd 100755 --- a/lib/oath/views/account_dialog.dart +++ b/lib/oath/views/account_dialog.dart @@ -133,25 +133,24 @@ class AccountDialog extends ConsumerWidget { child: SizedBox( width: 320, child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12.0), + padding: const EdgeInsets.symmetric( + vertical: 12.0, horizontal: 12.0), child: Column( children: [ - Text( - formatOathCode(code), - softWrap: false, - style: expired - ? Theme.of(context) - .textTheme - .headline2 - ?.copyWith(color: Colors.grey) - : Theme.of(context).textTheme.headline2, - ), - Text( - label, - overflow: TextOverflow.fade, - maxLines: 1, - softWrap: false, + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + formatOathCode(code), + softWrap: false, + style: expired + ? Theme.of(context) + .textTheme + .headline2 + ?.copyWith(color: Colors.grey) + : Theme.of(context).textTheme.headline2, + ), ), + Text(label), Padding( padding: const EdgeInsets.all(8.0), child: SizedBox.square( From 3931ae86fb68837a9928248f4470aec63a7a76b6 Mon Sep 17 00:00:00 2001 From: Dain Nilsson Date: Wed, 2 Mar 2022 15:25:47 +0100 Subject: [PATCH 4/8] Fix rename credential. * Update credential in dialog. * Only show rename button for >= 5.3. * Don't throw exception in Code lookup for renamed credential. --- lib/oath/state.dart | 7 ++-- lib/oath/views/account_dialog.dart | 43 +++++++++++++++-------- lib/oath/views/account_view.dart | 2 +- lib/oath/views/rename_account_dialog.dart | 4 +-- 4 files changed, 37 insertions(+), 19 deletions(-) diff --git a/lib/oath/state.dart b/lib/oath/state.dart index 2ad0afb4..8a68adb8 100755 --- a/lib/oath/state.dart +++ b/lib/oath/state.dart @@ -64,8 +64,11 @@ final codeProvider = final node = ref.watch(currentDeviceProvider); if (node != null) { return ref - .watch(credentialListProvider(node.path).select((pairs) => - pairs?.firstWhere((pair) => pair.credential == credential))) + .watch(credentialListProvider(node.path) + .select((pairs) => pairs?.firstWhere( + (pair) => pair.credential == credential, + orElse: () => OathPair(credential, null), + ))) ?.code; } return null; diff --git a/lib/oath/views/account_dialog.dart b/lib/oath/views/account_dialog.dart index a68fb5fd..6dd5a675 100755 --- a/lib/oath/views/account_dialog.dart +++ b/lib/oath/views/account_dialog.dart @@ -13,9 +13,9 @@ import 'rename_account_dialog.dart'; import 'utils.dart'; class AccountDialog extends ConsumerWidget { - final DeviceNode device; + final YubiKeyData deviceData; final OathCredential credential; - const AccountDialog(this.device, this.credential, {Key? key}) + const AccountDialog(this.deviceData, this.credential, {Key? key}) : super(key: key); List _buildActions(BuildContext context, WidgetRef ref, @@ -34,7 +34,8 @@ class AccountDialog extends ConsumerWidget { calculateCode( context, credential, - ref.read(credentialListProvider(device.path).notifier), + ref.read( + credentialListProvider(deviceData.node.path).notifier), ); } : null, @@ -61,23 +62,37 @@ class AccountDialog extends ConsumerWidget { ref.read(favoritesProvider.notifier).toggleFavorite(credential.id); }, ), - IconButton( - icon: const Icon(Icons.edit), - tooltip: 'Rename account', - onPressed: () { - showDialog( - context: context, - builder: (context) => RenameAccountDialog(device, credential), - ); - }, - ), + if (deviceData.info.version.major >= 5 && + deviceData.info.version.minor >= 3) + IconButton( + icon: const Icon(Icons.edit), + tooltip: 'Rename account', + onPressed: () async { + final renamed = await showDialog( + context: context, + builder: (context) => + RenameAccountDialog(deviceData.node, credential), + ); + if (renamed != null) { + // Replace this dialog with a new one, for the renamed credential. + Navigator.of(context).pop(); + await showDialog( + context: context, + builder: (context) { + return AccountDialog(deviceData, renamed); + }, + ); + } + }, + ), IconButton( icon: const Icon(Icons.delete_forever), tooltip: 'Delete account', onPressed: () async { final result = await showDialog( context: context, - builder: (context) => DeleteAccountDialog(device, credential), + builder: (context) => + DeleteAccountDialog(deviceData.node, credential), ); if (result) { Navigator.of(context).pop(); diff --git a/lib/oath/views/account_view.dart b/lib/oath/views/account_view.dart index 5df22108..66e061ae 100755 --- a/lib/oath/views/account_view.dart +++ b/lib/oath/views/account_view.dart @@ -88,7 +88,7 @@ class AccountView extends ConsumerWidget { showDialog( context: context, builder: (context) { - return AccountDialog(deviceData.node, credential); + return AccountDialog(deviceData, credential); }, ); }, diff --git a/lib/oath/views/rename_account_dialog.dart b/lib/oath/views/rename_account_dialog.dart index c8beedf6..2f074bff 100755 --- a/lib/oath/views/rename_account_dialog.dart +++ b/lib/oath/views/rename_account_dialog.dart @@ -100,11 +100,11 @@ class _RenameAccountDialogState extends ConsumerState { ElevatedButton( onPressed: isValid ? () async { - await ref + final renamed = await ref .read(credentialListProvider(widget.device.path).notifier) .renameAccount(credential, _issuer.isNotEmpty ? _issuer : null, _account); - Navigator.of(context).pop(); + Navigator.of(context).pop(renamed); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Account renamed'), From 06ec0b3887fa822172cc0d9504b23303461cc0fa Mon Sep 17 00:00:00 2001 From: Dain Nilsson Date: Thu, 3 Mar 2022 10:01:36 +0100 Subject: [PATCH 5/8] Add right-click meny to accounts. This also moves common Account view related code into a reusable Mixin class. --- lib/oath/views/account_dialog.dart | 149 ++++++++--------------- lib/oath/views/account_list.dart | 2 - lib/oath/views/account_mixin.dart | 183 +++++++++++++++++++++++++++++ lib/oath/views/account_view.dart | 177 ++++++++++++++-------------- lib/oath/views/utils.dart | 55 --------- 5 files changed, 321 insertions(+), 245 deletions(-) create mode 100755 lib/oath/views/account_mixin.dart diff --git a/lib/oath/views/account_dialog.dart b/lib/oath/views/account_dialog.dart index 6dd5a675..5edd3c85 100755 --- a/lib/oath/views/account_dialog.dart +++ b/lib/oath/views/account_dialog.dart @@ -1,118 +1,61 @@ import 'dart:ui'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../app/models.dart'; import '../../widgets/circle_timer.dart'; import '../models.dart'; -import '../state.dart'; -import 'delete_account_dialog.dart'; -import 'rename_account_dialog.dart'; -import 'utils.dart'; +import 'account_mixin.dart'; -class AccountDialog extends ConsumerWidget { - final YubiKeyData deviceData; +class AccountDialog extends ConsumerWidget with AccountMixin { + @override final OathCredential credential; - const AccountDialog(this.deviceData, this.credential, {Key? key}) - : super(key: key); + const AccountDialog(this.credential, {Key? key}) : super(key: key); - List _buildActions(BuildContext context, WidgetRef ref, - OathCode? code, bool expired, bool favorite) { - final manual = - credential.touchRequired || credential.oathType == OathType.hotp; - final ready = expired || credential.oathType == OathType.hotp; + @override + Future renameCredential( + BuildContext context, WidgetRef ref) async { + final renamed = await super.renameCredential(context, ref); + if (renamed != null) { + // Replace this dialog with a new one, for the renamed credential. + Navigator.of(context).pop(); + await showDialog( + context: context, + builder: (context) { + return AccountDialog(renamed); + }, + ); + } + return renamed; + } - return [ - if (manual) - IconButton( - icon: const Icon(Icons.refresh), - tooltip: 'Calculate', - onPressed: ready - ? () { - calculateCode( - context, - credential, - ref.read( - credentialListProvider(deviceData.node.path).notifier), - ); - } - : null, - ), - IconButton( - icon: const Icon(Icons.copy), - tooltip: 'Copy to clipboard', - onPressed: code == null || expired - ? null - : () { - Clipboard.setData(ClipboardData(text: code.value)); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Code copied to clipboard'), - duration: Duration(seconds: 2), - ), - ); - }, - ), - IconButton( - icon: Icon(favorite ? Icons.star : Icons.star_border), - tooltip: favorite ? 'Remove from favorites' : 'Add to favorites', - onPressed: () { - ref.read(favoritesProvider.notifier).toggleFavorite(credential.id); - }, - ), - if (deviceData.info.version.major >= 5 && - deviceData.info.version.minor >= 3) - IconButton( - icon: const Icon(Icons.edit), - tooltip: 'Rename account', - onPressed: () async { - final renamed = await showDialog( - context: context, - builder: (context) => - RenameAccountDialog(deviceData.node, credential), - ); - if (renamed != null) { - // Replace this dialog with a new one, for the renamed credential. - Navigator.of(context).pop(); - await showDialog( - context: context, - builder: (context) { - return AccountDialog(deviceData, renamed); - }, - ); - } - }, - ), - IconButton( - icon: const Icon(Icons.delete_forever), - tooltip: 'Delete account', - onPressed: () async { - final result = await showDialog( - context: context, - builder: (context) => - DeleteAccountDialog(deviceData.node, credential), - ); - if (result) { - Navigator.of(context).pop(); - } - }, - ), - ]; + @override + Future deleteCredential(BuildContext context, WidgetRef ref) async { + final deleted = await super.deleteCredential(context, ref); + if (deleted) { + Navigator.of(context).pop(); + } + return deleted; + } + + List _buildActions(BuildContext context, WidgetRef ref) { + return buildActions(ref).map((e) { + final action = e.action; + return IconButton( + icon: e.icon, + tooltip: e.text, + onPressed: action != null + ? () { + action(context); + } + : null, + ); + }).toList(); } @override Widget build(BuildContext context, WidgetRef ref) { - final label = credential.issuer != null - ? '${credential.issuer} (${credential.name})' - : credential.name; - - final code = ref.watch(codeProvider(credential)); - final expired = code == null || - (credential.oathType == OathType.totp && - ref.watch(expiredProvider(code.validTo))); - final favorite = ref.watch(favoritesProvider).contains(credential.id); + final code = getCode(ref); return BackdropFilter( filter: ImageFilter.blur(sigmaX: 3, sigmaY: 3), @@ -123,7 +66,7 @@ class AccountDialog extends ConsumerWidget { child: Scaffold( backgroundColor: Colors.transparent, appBar: AppBar( - actions: _buildActions(context, ref, code, expired, favorite), + actions: _buildActions(context, ref), ), body: LayoutBuilder(builder: (context, constraints) { return ListView( @@ -155,9 +98,9 @@ class AccountDialog extends ConsumerWidget { FittedBox( fit: BoxFit.scaleDown, child: Text( - formatOathCode(code), + formatCode(ref), softWrap: false, - style: expired + style: isExpired(ref) ? Theme.of(context) .textTheme .headline2 diff --git a/lib/oath/views/account_list.dart b/lib/oath/views/account_list.dart index 8047dfc2..85e78941 100755 --- a/lib/oath/views/account_list.dart +++ b/lib/oath/views/account_list.dart @@ -93,7 +93,6 @@ class _AccountListState extends State { (entry) => AccountView( widget.deviceData, entry.credential, - entry.code, focusNode: _focusNodes[entry.credential], ), ), @@ -108,7 +107,6 @@ class _AccountListState extends State { (entry) => AccountView( widget.deviceData, entry.credential, - entry.code, focusNode: _focusNodes[entry.credential], ), ), diff --git a/lib/oath/views/account_mixin.dart b/lib/oath/views/account_mixin.dart new file mode 100755 index 00000000..185f5823 --- /dev/null +++ b/lib/oath/views/account_mixin.dart @@ -0,0 +1,183 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app/models.dart'; +import '../../app/state.dart'; +import '../models.dart'; +import '../state.dart'; +import 'delete_account_dialog.dart'; +import 'rename_account_dialog.dart'; + +mixin AccountMixin { + OathCredential get credential; + + @protected + String get label => credential.issuer != null + ? '${credential.issuer} (${credential.name})' + : credential.name; + + @protected + OathCode? getCode(WidgetRef ref) => ref.watch(codeProvider(credential)); + + @protected + String formatCode(WidgetRef ref) { + final value = getCode(ref)?.value; + if (value == null) { + return '••• •••'; + } else if (value.length < 6) { + return value; + } else { + var i = value.length ~/ 2; + return value.substring(0, i) + ' ' + value.substring(i); + } + } + + @protected + bool isExpired(WidgetRef ref) { + final code = getCode(ref); + return code == null || + (credential.oathType == OathType.totp && + ref.watch(expiredProvider(code.validTo))); + } + + @protected + bool isFavorite(WidgetRef ref) => + ref.watch(favoritesProvider).contains(credential.id); + + @protected + Future calculateCode(BuildContext context, WidgetRef ref) async { + Function? close; + if (credential.touchRequired) { + close = ScaffoldMessenger.of(context) + .showSnackBar( + const SnackBar( + content: Text('Touch your YubiKey'), + duration: Duration(seconds: 30), + ), + ) + .close; + } else if (credential.oathType == OathType.hotp) { + final showPrompt = Timer(const Duration(milliseconds: 500), () { + close = ScaffoldMessenger.of(context) + .showSnackBar( + const SnackBar( + content: Text('Touch your YubiKey'), + duration: Duration(seconds: 30), + ), + ) + .close; + }); + close = showPrompt.cancel; + } + try { + final node = ref.read(currentDeviceProvider)!; + return await ref + .read(credentialListProvider(node.path).notifier) + .calculate(credential); + } finally { + // Hide the touch prompt when done + close?.call(); + } + } + + @protected + void copyToClipboard(BuildContext context, WidgetRef ref) { + final code = getCode(ref); + if (code != null) { + Clipboard.setData(ClipboardData(text: code.value)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Code copied to clipboard'), + duration: Duration(seconds: 2), + ), + ); + } + } + + @protected + Future renameCredential( + BuildContext context, WidgetRef ref) async { + final node = ref.read(currentDeviceProvider)!; + return await showDialog( + context: context, + builder: (context) => RenameAccountDialog(node, credential), + ); + } + + @protected + Future deleteCredential(BuildContext context, WidgetRef ref) async { + final node = ref.read(currentDeviceProvider)!; + return await showDialog( + context: context, + builder: (context) => DeleteAccountDialog(node, credential), + ); + } + + @protected + List buildActions(WidgetRef ref) { + final deviceData = ref.watch(currentDeviceDataProvider); + if (deviceData == null) { + return []; + } + final code = getCode(ref); + final expired = isExpired(ref); + final manual = + credential.touchRequired || credential.oathType == OathType.hotp; + final ready = expired || credential.oathType == OathType.hotp; + final favorite = isFavorite(ref); + + return [ + if (manual) + MenuAction( + text: 'Calculate', + icon: const Icon(Icons.refresh), + action: ready + ? (context) { + calculateCode(context, ref); + } + : null, + ), + MenuAction( + text: 'Copy to clipboard', + icon: const Icon(Icons.copy), + action: code == null || expired + ? null + : (context) { + Clipboard.setData(ClipboardData(text: code.value)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Code copied to clipboard'), + duration: Duration(seconds: 2), + ), + ); + }, + ), + MenuAction( + text: favorite ? 'Remove from favorites' : 'Add to favorites', + icon: Icon(favorite ? Icons.star : Icons.star_border), + action: (context) { + ref.read(favoritesProvider.notifier).toggleFavorite(credential.id); + }, + ), + if (deviceData.info.version.major >= 5 && + deviceData.info.version.minor >= 3) + MenuAction( + icon: const Icon(Icons.edit), + text: 'Rename account', + action: (context) async { + await renameCredential(context, ref); + }, + ), + MenuAction( + text: 'Delete account', + icon: const Icon(Icons.delete_forever), + action: (context) async { + await deleteCredential(context, ref); + }, + ), + ]; + } +} diff --git a/lib/oath/views/account_view.dart b/lib/oath/views/account_view.dart index 66e061ae..1edbbef4 100755 --- a/lib/oath/views/account_view.dart +++ b/lib/oath/views/account_view.dart @@ -1,48 +1,23 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:yubico_authenticator/oath/views/account_dialog.dart'; import '../../widgets/circle_timer.dart'; import '../../app/models.dart'; import '../models.dart'; -import '../state.dart'; -import 'utils.dart'; +import 'account_dialog.dart'; +import 'account_mixin.dart'; -class AccountView extends ConsumerWidget { +class AccountView extends ConsumerWidget with AccountMixin { final YubiKeyData deviceData; + @override final OathCredential credential; - final OathCode? code; final FocusNode? focusNode; - const AccountView(this.deviceData, this.credential, this.code, - {Key? key, this.focusNode}) + AccountView(this.deviceData, this.credential, {Key? key, this.focusNode}) : super(key: key); - _copyToClipboard(BuildContext context, WidgetRef ref, bool trigger) async { - final scaffoldMessenger = ScaffoldMessenger.of(context); - String value; - if (trigger) { - final updated = await calculateCode( - context, - credential, - ref.read(credentialListProvider(deviceData.node.path).notifier), - ); - value = updated.value; - } else { - value = code!.value; - } - await Clipboard.setData(ClipboardData(text: value)); - await scaffoldMessenger - .showSnackBar( - const SnackBar( - content: Text('Code copied to clipboard'), - duration: Duration(seconds: 2), - ), - ) - .closed; - } - - Color _iconColor(String label, int shade) { + Color _iconColor(int shade) { final colors = [ Colors.red[shade], Colors.pink[shade], @@ -67,70 +42,102 @@ class AccountView extends ConsumerWidget { return colors[label.hashCode % colors.length]!; } + List _buildPopupMenu(BuildContext context, WidgetRef ref) { + return buildActions(ref).map((e) { + final action = e.action; + return PopupMenuItem( + child: ListTile( + leading: e.icon, + title: Text(e.text), + ), + enabled: action != null, + onTap: () { + Timer(Duration.zero, () { + action?.call(context); + }); + }, + ); + }).toList(); + } + @override Widget build(BuildContext context, WidgetRef ref) { - final code = this.code; - final label = credential.issuer != null - ? '${credential.issuer} (${credential.name})' - : credential.name; - final expired = code == null || - (credential.oathType == OathType.totp && - ref.watch(expiredProvider(code.validTo))); - final trigger = code == null || + final code = getCode(ref); + final expired = isExpired(ref); + final calculateReady = code == null || credential.oathType == OathType.hotp || (credential.touchRequired && expired); final darkMode = Theme.of(context).brightness == Brightness.dark; - return ListTile( - focusNode: focusNode, - onTap: () { - showDialog( + return GestureDetector( + onSecondaryTapDown: (details) { + showMenu( context: context, - builder: (context) { - return AccountDialog(deviceData, credential); - }, + position: RelativeRect.fromLTRB( + details.globalPosition.dx, details.globalPosition.dy, 0, 0), + items: _buildPopupMenu(context, ref), ); }, - onLongPress: () { - _copyToClipboard(context, ref, trigger); - }, - leading: CircleAvatar( - foregroundColor: darkMode ? Colors.black : Colors.white, - backgroundColor: _iconColor(label, darkMode ? 300 : 400), - child: Text( - (credential.issuer ?? credential.name).characters.first.toUpperCase(), - style: const TextStyle(fontSize: 18), + child: ListTile( + focusNode: focusNode, + onTap: () { + showDialog( + context: context, + builder: (context) { + return AccountDialog(credential); + }, + ); + }, + onLongPress: () async { + if (calculateReady) { + await calculateCode( + context, + ref, + ); + } + copyToClipboard(context, ref); + }, + leading: CircleAvatar( + foregroundColor: darkMode ? Colors.black : Colors.white, + backgroundColor: _iconColor(darkMode ? 300 : 400), + child: Text( + (credential.issuer ?? credential.name) + .characters + .first + .toUpperCase(), + style: const TextStyle(fontSize: 18), + ), ), - ), - title: Text( - formatOathCode(code), - style: expired - ? Theme.of(context) - .textTheme - .headline5 - ?.copyWith(color: Colors.grey) - : Theme.of(context).textTheme.headline5, - ), - subtitle: Text( - label, - style: Theme.of(context).textTheme.caption, - overflow: TextOverflow.fade, - maxLines: 1, - softWrap: false, - ), - trailing: trigger - ? Icon( - credential.touchRequired ? Icons.touch_app : Icons.refresh, - size: 18, - ) - : SizedBox.square( - dimension: 16, - child: CircleTimer( - code.validFrom * 1000, - code.validTo * 1000, + title: Text( + formatCode(ref), + style: expired + ? Theme.of(context) + .textTheme + .headline5 + ?.copyWith(color: Colors.grey) + : Theme.of(context).textTheme.headline5, + ), + subtitle: Text( + label, + style: Theme.of(context).textTheme.caption, + overflow: TextOverflow.fade, + maxLines: 1, + softWrap: false, + ), + trailing: calculateReady + ? Icon( + credential.touchRequired ? Icons.touch_app : Icons.refresh, + size: 18, + ) + : SizedBox.square( + dimension: 16, + child: CircleTimer( + code.validFrom * 1000, + code.validTo * 1000, + ), ), - ), + ), ); } } diff --git a/lib/oath/views/utils.dart b/lib/oath/views/utils.dart index 5931984c..96cb3b09 100755 --- a/lib/oath/views/utils.dart +++ b/lib/oath/views/utils.dart @@ -1,11 +1,7 @@ -import 'dart:async'; import 'dart:math'; -import 'package:flutter/material.dart'; - import '../models.dart'; import '../../core/models.dart'; -import '../state.dart'; /// Calculates the available space for issuer and account name. /// @@ -34,54 +30,3 @@ Pair getRemainingKeySpace( remaining - issuerSpace, ); } - -/// Formats an OATH code for display. -/// -/// If the [OathCode] is null, then a placeholder string is returned. -String formatOathCode(OathCode? code) { - var value = code?.value; - if (value == null) { - return '••• •••'; - } else if (value.length < 6) { - return value; - } else { - var i = value.length ~/ 2; - return value.substring(0, i) + ' ' + value.substring(i); - } -} - -/// Calculates a new OATH code for a credential. -/// -/// This function will take care of prompting the user for touch if needed. -Future calculateCode(BuildContext context, OathCredential credential, - OathCredentialListNotifier notifier) async { - Function? close; - if (credential.touchRequired) { - close = ScaffoldMessenger.of(context) - .showSnackBar( - const SnackBar( - content: Text('Touch your YubiKey'), - duration: Duration(seconds: 30), - ), - ) - .close; - } else if (credential.oathType == OathType.hotp) { - final showPrompt = Timer(const Duration(milliseconds: 500), () { - close = ScaffoldMessenger.of(context) - .showSnackBar( - const SnackBar( - content: Text('Touch your YubiKey'), - duration: Duration(seconds: 30), - ), - ) - .close; - }); - close = showPrompt.cancel; - } - try { - return await notifier.calculate(credential); - } finally { - // Hide the touch prompt when done - close?.call(); - } -} From df689945a4aecc12c57bb90cae73e191507562fc Mon Sep 17 00:00:00 2001 From: Dain Nilsson Date: Thu, 3 Mar 2022 11:20:47 +0100 Subject: [PATCH 6/8] Visual tweaks to OATH popup. - Smaller text, less padding. - Fix popup position. --- lib/oath/views/account_view.dart | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/oath/views/account_view.dart b/lib/oath/views/account_view.dart index 1edbbef4..b95628ea 100755 --- a/lib/oath/views/account_view.dart +++ b/lib/oath/views/account_view.dart @@ -49,9 +49,14 @@ class AccountView extends ConsumerWidget with AccountMixin { child: ListTile( leading: e.icon, title: Text(e.text), + dense: true, + contentPadding: EdgeInsets.zero, ), enabled: action != null, onTap: () { + // As soon as onTap returns, the Navigator is popped, + // closing the topmost item. Since we sometimes open new dialogs in + // the action, make sure that happens *after* the pop. Timer(Duration.zero, () { action?.call(context); }); @@ -75,7 +80,11 @@ class AccountView extends ConsumerWidget with AccountMixin { showMenu( context: context, position: RelativeRect.fromLTRB( - details.globalPosition.dx, details.globalPosition.dy, 0, 0), + details.globalPosition.dx, + details.globalPosition.dy, + details.globalPosition.dx, + 0, + ), items: _buildPopupMenu(context, ref), ); }, From e77163dac2b2375fcc64ff6a33de1d591225c82b Mon Sep 17 00:00:00 2001 From: Dain Nilsson Date: Thu, 3 Mar 2022 13:55:07 +0100 Subject: [PATCH 7/8] Use "pinned" instead of "favorite". --- lib/app/models.dart | 2 +- lib/app/models.freezed.dart | 18 ++++----- lib/oath/views/account_dialog.dart | 2 +- lib/oath/views/account_list.dart | 11 +++--- lib/oath/views/account_mixin.dart | 63 +++++++++++++++++++++++++++--- lib/oath/views/account_view.dart | 2 +- 6 files changed, 75 insertions(+), 23 deletions(-) diff --git a/lib/app/models.dart b/lib/app/models.dart index 2e235a1a..115eb14e 100755 --- a/lib/app/models.dart +++ b/lib/app/models.dart @@ -38,7 +38,7 @@ class DeviceNode with _$DeviceNode { class MenuAction with _$MenuAction { factory MenuAction( {required String text, - required Icon icon, + required Widget icon, void Function(BuildContext context)? action}) = _MenuAction; } diff --git a/lib/app/models.freezed.dart b/lib/app/models.freezed.dart index f3245477..538415f2 100755 --- a/lib/app/models.freezed.dart +++ b/lib/app/models.freezed.dart @@ -656,7 +656,7 @@ class _$MenuActionTearOff { _MenuAction call( {required String text, - required Icon icon, + required Widget icon, void Function(BuildContext)? action}) { return _MenuAction( text: text, @@ -672,7 +672,7 @@ const $MenuAction = _$MenuActionTearOff(); /// @nodoc mixin _$MenuAction { String get text => throw _privateConstructorUsedError; - Icon get icon => throw _privateConstructorUsedError; + Widget get icon => throw _privateConstructorUsedError; void Function(BuildContext)? get action => throw _privateConstructorUsedError; @JsonKey(ignore: true) @@ -685,7 +685,7 @@ abstract class $MenuActionCopyWith<$Res> { factory $MenuActionCopyWith( MenuAction value, $Res Function(MenuAction) then) = _$MenuActionCopyWithImpl<$Res>; - $Res call({String text, Icon icon, void Function(BuildContext)? action}); + $Res call({String text, Widget icon, void Function(BuildContext)? action}); } /// @nodoc @@ -710,7 +710,7 @@ class _$MenuActionCopyWithImpl<$Res> implements $MenuActionCopyWith<$Res> { icon: icon == freezed ? _value.icon : icon // ignore: cast_nullable_to_non_nullable - as Icon, + as Widget, action: action == freezed ? _value.action : action // ignore: cast_nullable_to_non_nullable @@ -725,7 +725,7 @@ abstract class _$MenuActionCopyWith<$Res> implements $MenuActionCopyWith<$Res> { _MenuAction value, $Res Function(_MenuAction) then) = __$MenuActionCopyWithImpl<$Res>; @override - $Res call({String text, Icon icon, void Function(BuildContext)? action}); + $Res call({String text, Widget icon, void Function(BuildContext)? action}); } /// @nodoc @@ -752,7 +752,7 @@ class __$MenuActionCopyWithImpl<$Res> extends _$MenuActionCopyWithImpl<$Res> icon: icon == freezed ? _value.icon : icon // ignore: cast_nullable_to_non_nullable - as Icon, + as Widget, action: action == freezed ? _value.action : action // ignore: cast_nullable_to_non_nullable @@ -769,7 +769,7 @@ class _$_MenuAction implements _MenuAction { @override final String text; @override - final Icon icon; + final Widget icon; @override final void Function(BuildContext)? action; @@ -804,13 +804,13 @@ class _$_MenuAction implements _MenuAction { abstract class _MenuAction implements MenuAction { factory _MenuAction( {required String text, - required Icon icon, + required Widget icon, void Function(BuildContext)? action}) = _$_MenuAction; @override String get text; @override - Icon get icon; + Widget get icon; @override void Function(BuildContext)? get action; @override diff --git a/lib/oath/views/account_dialog.dart b/lib/oath/views/account_dialog.dart index 5edd3c85..61469bf3 100755 --- a/lib/oath/views/account_dialog.dart +++ b/lib/oath/views/account_dialog.dart @@ -39,7 +39,7 @@ class AccountDialog extends ConsumerWidget with AccountMixin { } List _buildActions(BuildContext context, WidgetRef ref) { - return buildActions(ref).map((e) { + return buildActions(context, ref).map((e) { final action = e.action; return IconButton( icon: e.icon, diff --git a/lib/oath/views/account_list.dart b/lib/oath/views/account_list.dart index 85e78941..d68db8bc 100755 --- a/lib/oath/views/account_list.dart +++ b/lib/oath/views/account_list.dart @@ -72,24 +72,25 @@ class _AccountListState extends State { ); } - final favCreds = widget.credentials + final pinnedCreds = widget.credentials .where((entry) => widget.favorites.contains(entry.credential.id)); final creds = widget.credentials .where((entry) => !widget.favorites.contains(entry.credential.id)); - _credentials = favCreds.followedBy(creds).map((e) => e.credential).toList(); + _credentials = + pinnedCreds.followedBy(creds).map((e) => e.credential).toList(); _updateFocusNodes(); return ListView( children: [ - if (favCreds.isNotEmpty) + if (pinnedCreds.isNotEmpty) ListTile( title: Text( - 'FAVORITES', + 'PINNED', style: Theme.of(context).textTheme.bodyText2, ), ), - ...favCreds.map( + ...pinnedCreds.map( (entry) => AccountView( widget.deviceData, entry.credential, diff --git a/lib/oath/views/account_mixin.dart b/lib/oath/views/account_mixin.dart index 185f5823..f05b5d5a 100755 --- a/lib/oath/views/account_mixin.dart +++ b/lib/oath/views/account_mixin.dart @@ -11,6 +11,47 @@ import '../state.dart'; import 'delete_account_dialog.dart'; import 'rename_account_dialog.dart'; +class _StrikethroughClipper extends CustomClipper { + @override + Path getClip(Size size) { + Path path = Path() + ..moveTo(0, 2) + ..lineTo(0, size.height) + ..lineTo(size.width - 2, size.height) + ..lineTo(0, 2) + ..moveTo(2, 0) + ..lineTo(size.width, size.height - 2) + ..lineTo(size.width, 0) + ..lineTo(2, 0) + ..close(); + return path; + } + + @override + bool shouldReclip(covariant CustomClipper oldClipper) { + return false; + } +} + +class _StrikethroughPainter extends CustomPainter { + final Color color; + _StrikethroughPainter(this.color); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint(); + paint.color = color; + paint.strokeWidth = 1.3; + canvas.drawLine(Offset(size.width * 0.15, size.height * 0.15), + Offset(size.width * 0.8, size.height * 0.8), paint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) { + return false; + } +} + mixin AccountMixin { OathCredential get credential; @@ -44,7 +85,7 @@ mixin AccountMixin { } @protected - bool isFavorite(WidgetRef ref) => + bool isPinned(WidgetRef ref) => ref.watch(favoritesProvider).contains(credential.id); @protected @@ -117,7 +158,7 @@ mixin AccountMixin { } @protected - List buildActions(WidgetRef ref) { + List buildActions(BuildContext context, WidgetRef ref) { final deviceData = ref.watch(currentDeviceDataProvider); if (deviceData == null) { return []; @@ -127,7 +168,7 @@ mixin AccountMixin { final manual = credential.touchRequired || credential.oathType == OathType.hotp; final ready = expired || credential.oathType == OathType.hotp; - final favorite = isFavorite(ref); + final pinned = isPinned(ref); return [ if (manual) @@ -156,8 +197,18 @@ mixin AccountMixin { }, ), MenuAction( - text: favorite ? 'Remove from favorites' : 'Add to favorites', - icon: Icon(favorite ? Icons.star : Icons.star_border), + text: pinned ? 'Remove pin' : 'Pin account', + //TODO: Replace this with a custom icon. + //Icon(pinned ? Icons.push_pin_remove : Icons.push_pin), + icon: pinned + ? CustomPaint( + painter: _StrikethroughPainter( + Theme.of(context).iconTheme.color ?? Colors.black), + child: ClipPath( + clipper: _StrikethroughClipper(), + child: const Icon(Icons.push_pin)), + ) + : const Icon(Icons.push_pin), action: (context) { ref.read(favoritesProvider.notifier).toggleFavorite(credential.id); }, @@ -173,7 +224,7 @@ mixin AccountMixin { ), MenuAction( text: 'Delete account', - icon: const Icon(Icons.delete_forever), + icon: const Icon(Icons.delete), action: (context) async { await deleteCredential(context, ref); }, diff --git a/lib/oath/views/account_view.dart b/lib/oath/views/account_view.dart index b95628ea..421fd0a0 100755 --- a/lib/oath/views/account_view.dart +++ b/lib/oath/views/account_view.dart @@ -43,7 +43,7 @@ class AccountView extends ConsumerWidget with AccountMixin { } List _buildPopupMenu(BuildContext context, WidgetRef ref) { - return buildActions(ref).map((e) { + return buildActions(context, ref).map((e) { final action = e.action; return PopupMenuItem( child: ListTile( From 42a7a467b3a63f01ebf0013e67fc434793b1fec1 Mon Sep 17 00:00:00 2001 From: Dain Nilsson Date: Thu, 3 Mar 2022 14:23:51 +0100 Subject: [PATCH 8/8] Change text "Unpin account" --- lib/oath/views/account_mixin.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/oath/views/account_mixin.dart b/lib/oath/views/account_mixin.dart index f05b5d5a..8be27385 100755 --- a/lib/oath/views/account_mixin.dart +++ b/lib/oath/views/account_mixin.dart @@ -197,7 +197,7 @@ mixin AccountMixin { }, ), MenuAction( - text: pinned ? 'Remove pin' : 'Pin account', + text: pinned ? 'Unpin account' : 'Pin account', //TODO: Replace this with a custom icon. //Icon(pinned ? Icons.push_pin_remove : Icons.push_pin), icon: pinned