mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-11-22 00:12:09 +03:00
Merge PR #53.
This commit is contained in:
commit
79de2dd2e1
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -50,6 +50,55 @@ abstract class OathCredentialListNotifier
|
||||
Future<void> deleteAccount(OathCredential credential);
|
||||
}
|
||||
|
||||
final credentialsProvider = Provider.autoDispose<List<OathCredential>?>((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<OathCode?, OathCredential>((ref, credential) {
|
||||
final node = ref.watch(currentDeviceProvider);
|
||||
if (node != null) {
|
||||
return ref
|
||||
.watch(credentialListProvider(node.path)
|
||||
.select((pairs) => pairs?.firstWhere(
|
||||
(pair) => pair.credential == credential,
|
||||
orElse: () => OathPair(credential, null),
|
||||
)))
|
||||
?.code;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
final expiredProvider =
|
||||
StateNotifierProvider.autoDispose.family<_ExpireNotifier, bool, int>(
|
||||
(ref, expiry) =>
|
||||
_ExpireNotifier(DateTime.now().millisecondsSinceEpoch, expiry * 1000),
|
||||
);
|
||||
|
||||
class _ExpireNotifier extends StateNotifier<bool> {
|
||||
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<FavoritesNotifier, List<String>>(
|
||||
(ref) => FavoritesNotifier(ref.watch(prefProvider)));
|
||||
|
139
lib/oath/views/account_dialog.dart
Executable file
139
lib/oath/views/account_dialog.dart
Executable file
@ -0,0 +1,139 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../widgets/circle_timer.dart';
|
||||
import '../models.dart';
|
||||
import 'account_mixin.dart';
|
||||
|
||||
class AccountDialog extends ConsumerWidget with AccountMixin {
|
||||
@override
|
||||
final OathCredential credential;
|
||||
const AccountDialog(this.credential, {Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Future<OathCredential?> 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;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> deleteCredential(BuildContext context, WidgetRef ref) async {
|
||||
final deleted = await super.deleteCredential(context, ref);
|
||||
if (deleted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
List<Widget> _buildActions(BuildContext context, WidgetRef ref) {
|
||||
return buildActions(context, 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 code = getCode(ref);
|
||||
|
||||
return BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 3, sigmaY: 3),
|
||||
child: Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0.0,
|
||||
insetPadding: const EdgeInsets.all(0),
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: AppBar(
|
||||
actions: _buildActions(context, ref),
|
||||
),
|
||||
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, horizontal: 12.0),
|
||||
child: Column(
|
||||
children: [
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
formatCode(ref),
|
||||
softWrap: false,
|
||||
style: isExpired(ref)
|
||||
? 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,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -72,28 +72,28 @@ class _AccountListState extends State<AccountList> {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
entry.code,
|
||||
focusNode: _focusNodes[entry.credential],
|
||||
),
|
||||
),
|
||||
@ -108,7 +108,6 @@ class _AccountListState extends State<AccountList> {
|
||||
(entry) => AccountView(
|
||||
widget.deviceData,
|
||||
entry.credential,
|
||||
entry.code,
|
||||
focusNode: _focusNodes[entry.credential],
|
||||
),
|
||||
),
|
||||
|
234
lib/oath/views/account_mixin.dart
Executable file
234
lib/oath/views/account_mixin.dart
Executable file
@ -0,0 +1,234 @@
|
||||
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';
|
||||
|
||||
class _StrikethroughClipper extends CustomClipper<Path> {
|
||||
@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<Path> 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;
|
||||
|
||||
@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 isPinned(WidgetRef ref) =>
|
||||
ref.watch(favoritesProvider).contains(credential.id);
|
||||
|
||||
@protected
|
||||
Future<OathCode> 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<OathCredential?> renameCredential(
|
||||
BuildContext context, WidgetRef ref) async {
|
||||
final node = ref.read(currentDeviceProvider)!;
|
||||
return await showDialog(
|
||||
context: context,
|
||||
builder: (context) => RenameAccountDialog(node, credential),
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
Future<bool> deleteCredential(BuildContext context, WidgetRef ref) async {
|
||||
final node = ref.read(currentDeviceProvider)!;
|
||||
return await showDialog(
|
||||
context: context,
|
||||
builder: (context) => DeleteAccountDialog(node, credential),
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
List<MenuAction> buildActions(BuildContext context, 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 pinned = isPinned(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: pinned ? 'Unpin account' : '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);
|
||||
},
|
||||
),
|
||||
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),
|
||||
action: (context) async {
|
||||
await deleteCredential(context, ref);
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
@ -1,175 +1,23 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.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';
|
||||
import 'account_dialog.dart';
|
||||
import 'account_mixin.dart';
|
||||
|
||||
final _expireProvider =
|
||||
StateNotifierProvider.autoDispose.family<_ExpireNotifier, bool, int>(
|
||||
(ref, expiry) =>
|
||||
_ExpireNotifier(DateTime.now().millisecondsSinceEpoch, expiry * 1000),
|
||||
);
|
||||
|
||||
class _ExpireNotifier extends StateNotifier<bool> {
|
||||
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<bool>((ref) => false);
|
||||
|
||||
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);
|
||||
|
||||
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<PopupMenuEntry> _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<OathCode> _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();
|
||||
}
|
||||
}
|
||||
|
||||
Color _iconColor(String label, int shade) {
|
||||
Color _iconColor(int shade) {
|
||||
final colors = [
|
||||
Colors.red[shade],
|
||||
Colors.pink[shade],
|
||||
@ -194,86 +42,110 @@ class AccountView extends ConsumerWidget {
|
||||
return colors[label.hashCode % colors.length]!;
|
||||
}
|
||||
|
||||
List<PopupMenuItem> _buildPopupMenu(BuildContext context, WidgetRef ref) {
|
||||
return buildActions(context, ref).map((e) {
|
||||
final action = e.action;
|
||||
return PopupMenuItem(
|
||||
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);
|
||||
});
|
||||
},
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final code = this.code;
|
||||
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 trigger = code == null ||
|
||||
expired &&
|
||||
(credential.touchRequired || credential.oathType == OathType.hotp);
|
||||
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: () {
|
||||
final focus = focusNode;
|
||||
if (focus != null && focus.hasFocus == false) {
|
||||
focus.requestFocus();
|
||||
} else {
|
||||
_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),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
formatCode(),
|
||||
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: 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
PopupMenuButton(
|
||||
child: Icon(Icons.adaptive.more),
|
||||
itemBuilder: (context) =>
|
||||
_buildPopupMenu(context, ref, trigger),
|
||||
),
|
||||
],
|
||||
return GestureDetector(
|
||||
onSecondaryTapDown: (details) {
|
||||
showMenu(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(
|
||||
details.globalPosition.dx,
|
||||
details.globalPosition.dy,
|
||||
details.globalPosition.dx,
|
||||
0,
|
||||
),
|
||||
],
|
||||
items: _buildPopupMenu(context, ref),
|
||||
);
|
||||
},
|
||||
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(
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -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<DeviceNode?>(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'),
|
||||
|
@ -100,11 +100,11 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
|
||||
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'),
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user