Refactor OATH views and actions.

This commit is contained in:
Dain Nilsson 2023-02-10 17:37:42 +01:00
parent 15cdf0d62c
commit 2d887593bb
No known key found for this signature in database
GPG Key ID: F04367096FBA95E8
11 changed files with 539 additions and 589 deletions

View File

@ -45,12 +45,12 @@ Future<void> showBottomMenu(
title: Text(a.text),
contentPadding:
const EdgeInsets.symmetric(horizontal: 24),
enabled: a.action != null,
onTap: a.action == null
enabled: a.intent != null,
onTap: a.intent == null
? null
: () {
Navigator.pop(context);
a.action?.call(context);
Actions.invoke(context, a.intent!);
},
))
.toList(),

View File

@ -124,7 +124,7 @@ class MenuAction with _$MenuAction {
required String text,
required Widget icon,
String? trailing,
void Function(BuildContext context)? action,
Intent? intent,
}) = _MenuAction;
}

View File

@ -628,7 +628,7 @@ mixin _$MenuAction {
String get text => throw _privateConstructorUsedError;
Widget get icon => throw _privateConstructorUsedError;
String? get trailing => throw _privateConstructorUsedError;
void Function(BuildContext)? get action => throw _privateConstructorUsedError;
Intent? get intent => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$MenuActionCopyWith<MenuAction> get copyWith =>
@ -641,11 +641,7 @@ abstract class $MenuActionCopyWith<$Res> {
MenuAction value, $Res Function(MenuAction) then) =
_$MenuActionCopyWithImpl<$Res, MenuAction>;
@useResult
$Res call(
{String text,
Widget icon,
String? trailing,
void Function(BuildContext)? action});
$Res call({String text, Widget icon, String? trailing, Intent? intent});
}
/// @nodoc
@ -664,7 +660,7 @@ class _$MenuActionCopyWithImpl<$Res, $Val extends MenuAction>
Object? text = null,
Object? icon = null,
Object? trailing = freezed,
Object? action = freezed,
Object? intent = freezed,
}) {
return _then(_value.copyWith(
text: null == text
@ -679,10 +675,10 @@ class _$MenuActionCopyWithImpl<$Res, $Val extends MenuAction>
? _value.trailing
: trailing // ignore: cast_nullable_to_non_nullable
as String?,
action: freezed == action
? _value.action
: action // ignore: cast_nullable_to_non_nullable
as void Function(BuildContext)?,
intent: freezed == intent
? _value.intent
: intent // ignore: cast_nullable_to_non_nullable
as Intent?,
) as $Val);
}
}
@ -695,11 +691,7 @@ abstract class _$$_MenuActionCopyWith<$Res>
__$$_MenuActionCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{String text,
Widget icon,
String? trailing,
void Function(BuildContext)? action});
$Res call({String text, Widget icon, String? trailing, Intent? intent});
}
/// @nodoc
@ -716,7 +708,7 @@ class __$$_MenuActionCopyWithImpl<$Res>
Object? text = null,
Object? icon = null,
Object? trailing = freezed,
Object? action = freezed,
Object? intent = freezed,
}) {
return _then(_$_MenuAction(
text: null == text
@ -731,10 +723,10 @@ class __$$_MenuActionCopyWithImpl<$Res>
? _value.trailing
: trailing // ignore: cast_nullable_to_non_nullable
as String?,
action: freezed == action
? _value.action
: action // ignore: cast_nullable_to_non_nullable
as void Function(BuildContext)?,
intent: freezed == intent
? _value.intent
: intent // ignore: cast_nullable_to_non_nullable
as Intent?,
));
}
}
@ -743,7 +735,7 @@ class __$$_MenuActionCopyWithImpl<$Res>
class _$_MenuAction implements _MenuAction {
_$_MenuAction(
{required this.text, required this.icon, this.trailing, this.action});
{required this.text, required this.icon, this.trailing, this.intent});
@override
final String text;
@ -752,11 +744,11 @@ class _$_MenuAction implements _MenuAction {
@override
final String? trailing;
@override
final void Function(BuildContext)? action;
final Intent? intent;
@override
String toString() {
return 'MenuAction(text: $text, icon: $icon, trailing: $trailing, action: $action)';
return 'MenuAction(text: $text, icon: $icon, trailing: $trailing, intent: $intent)';
}
@override
@ -768,11 +760,11 @@ class _$_MenuAction implements _MenuAction {
(identical(other.icon, icon) || other.icon == icon) &&
(identical(other.trailing, trailing) ||
other.trailing == trailing) &&
(identical(other.action, action) || other.action == action));
(identical(other.intent, intent) || other.intent == intent));
}
@override
int get hashCode => Object.hash(runtimeType, text, icon, trailing, action);
int get hashCode => Object.hash(runtimeType, text, icon, trailing, intent);
@JsonKey(ignore: true)
@override
@ -786,7 +778,7 @@ abstract class _MenuAction implements MenuAction {
{required final String text,
required final Widget icon,
final String? trailing,
final void Function(BuildContext)? action}) = _$_MenuAction;
final Intent? intent}) = _$_MenuAction;
@override
String get text;
@ -795,7 +787,7 @@ abstract class _MenuAction implements MenuAction {
@override
String? get trailing;
@override
void Function(BuildContext)? get action;
Intent? get intent;
@override
@JsonKey(ignore: true)
_$$_MenuActionCopyWith<_$_MenuAction> get copyWith =>

View File

@ -58,6 +58,14 @@ class AboutIntent extends Intent {
const AboutIntent();
}
class EditIntent extends Intent {
const EditIntent();
}
class DeleteIntent extends Intent {
const DeleteIntent();
}
final ctrlOrCmd =
Platform.isMacOS ? LogicalKeyboardKey.meta : LogicalKeyboardKey.control;

View File

@ -33,7 +33,7 @@ final searchProvider =
class SearchNotifier extends StateNotifier<String> {
SearchNotifier() : super('');
setFilter(String value) {
void setFilter(String value) {
state = value;
}
}
@ -143,7 +143,7 @@ class _ExpireNotifier extends StateNotifier<bool> {
}
@override
dispose() {
void dispose() {
_timer?.cancel();
super.dispose();
}
@ -158,7 +158,7 @@ class FavoritesNotifier extends StateNotifier<List<String>> {
final SharedPreferences _prefs;
FavoritesNotifier(this._prefs) : super(_prefs.getStringList(_key) ?? []);
toggleFavorite(String credentialId) {
void toggleFavorite(String credentialId) {
if (state.contains(credentialId)) {
state = state.toList()..remove(credentialId);
} else {
@ -167,7 +167,7 @@ class FavoritesNotifier extends StateNotifier<List<String>> {
_prefs.setStringList(_key, state);
}
renameCredential(String oldCredentialId, String newCredentialId) {
void renameCredential(String oldCredentialId, String newCredentialId) {
if (state.contains(oldCredentialId)) {
state = [newCredentialId, ...state.toList()..remove(oldCredentialId)];
_prefs.setStringList(_key, state);

View File

@ -25,46 +25,19 @@ import '../../app/state.dart';
import '../../core/models.dart';
import '../../core/state.dart';
import '../models.dart';
import 'account_mixin.dart';
import '../state.dart';
import 'account_helper.dart';
import 'actions.dart';
import 'delete_account_dialog.dart';
import 'rename_account_dialog.dart';
class AccountDialog extends ConsumerWidget with AccountMixin {
@override
class AccountDialog extends ConsumerWidget {
final OathCredential credential;
const AccountDialog(this.credential, {super.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.
await ref.read(withContextProvider)((context) async {
Navigator.of(context).pop();
await showBlurDialog(
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) {
await ref.read(withContextProvider)((context) async {
Navigator.of(context).pop();
});
}
return deleted;
}
List<Widget> _buildActions(BuildContext context, WidgetRef ref) {
final actions = buildActions(context, ref);
List<Widget> _buildActions(BuildContext context, AccountHelper helper) {
final actions = helper.buildActions();
final theme =
ButtonTheme.of(context).colorScheme ?? Theme.of(context).colorScheme;
@ -77,7 +50,7 @@ class AccountDialog extends ConsumerWidget with AccountMixin {
};
// If we can't copy, but can calculate, highlight that button instead
if (copy.action == null) {
if (copy.intent == null) {
final calculates = actions.where(((e) => e.text.startsWith('Calculate')));
if (calculates.isNotEmpty) {
colors[calculates.first] = Pair(theme.primary, theme.onPrimary);
@ -85,17 +58,17 @@ class AccountDialog extends ConsumerWidget with AccountMixin {
}
return actions.map((e) {
final action = e.action;
final intent = e.intent;
final color = colors[e] ?? Pair(theme.secondary, theme.onSecondary);
final tooltip = e.trailing != null ? '${e.text}\n${e.trailing}' : e.text;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 6.0),
child: CircleAvatar(
backgroundColor: action != null ? color.first : theme.secondary,
backgroundColor: intent != null ? color.first : theme.secondary,
foregroundColor: color.second,
child: IconButton(
style: IconButton.styleFrom(
backgroundColor: action != null ? color.first : theme.secondary,
backgroundColor: intent != null ? color.first : theme.secondary,
foregroundColor: color.second,
disabledBackgroundColor: theme.onSecondary.withOpacity(0.2),
fixedSize: const Size.square(38),
@ -103,9 +76,9 @@ class AccountDialog extends ConsumerWidget with AccountMixin {
icon: e.icon,
iconSize: 22,
tooltip: tooltip,
onPressed: action != null
onPressed: intent != null
? () {
action(context);
Actions.invoke(context, intent);
}
: null,
),
@ -117,104 +90,146 @@ class AccountDialog extends ConsumerWidget with AccountMixin {
@override
Widget build(BuildContext context, WidgetRef ref) {
// TODO: Solve this in a cleaner way
final currentDeviceData = ref.watch(currentDeviceDataProvider);
if (currentDeviceData is! AsyncData) {
final node = ref.watch(currentDeviceDataProvider).valueOrNull?.node;
if (node == null) {
// The rest of this method assumes there is a device, and will throw an exception if not.
// This will never be shown, as the dialog will be immediately closed
return const SizedBox();
}
final code = getCode(ref);
if (isValid(ref) && code == null) {
if (isDesktop ||
(isAndroid &&
currentDeviceData.value?.node.transport == Transport.usb)) {
Timer(Duration.zero, () => calculateCode(context, ref));
}
}
return Actions(
final helper = AccountHelper(context, ref, credential);
final subtitle = helper.subtitle;
return registerOathActions(
credential,
ref: ref,
actions: {
CopyIntent: CallbackAction(onInvoke: (_) async {
if (isExpired(code, ref)) {
await calculateCode(context, ref);
EditIntent: CallbackAction<EditIntent>(onInvoke: (_) async {
final credentials = ref.read(credentialsProvider);
final withContext = ref.read(withContextProvider);
final OathCredential? renamed =
await withContext((context) async => await showBlurDialog(
context: context,
builder: (context) => RenameAccountDialog(
node,
credential,
credentials,
),
));
if (renamed != null) {
// Replace the dialog with the renamed credential
await withContext((context) async {
Navigator.of(context).pop();
await showBlurDialog(
context: context,
builder: (context) {
return AccountDialog(renamed);
},
);
});
}
await ref.read(withContextProvider)(
(context) async {
copyToClipboard(
ref.watch(clipboardProvider), context, getCode(ref));
},
);
return null;
return renamed;
}),
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
final withContext = ref.read(withContextProvider);
final bool? deleted =
await ref.read(withContextProvider)((context) async =>
await showBlurDialog(
context: context,
builder: (context) => DeleteAccountDialog(
node,
credential,
),
) ??
false);
// Pop the account dialog if deleted
if (deleted == true) {
await withContext((context) async {
Navigator.of(context).pop();
});
}
return deleted;
}),
},
child: FocusScope(
autofocus: true,
child: AlertDialog(
title: Center(
child: Text(
title,
style: Theme.of(context).textTheme.headlineSmall,
softWrap: true,
textAlign: TextAlign.center,
builder: (context) {
if (helper.code == null &&
(isDesktop || node.transport == Transport.usb)) {
Timer.run(() {
Actions.invoke(context, const CalculateIntent());
});
}
return FocusScope(
autofocus: true,
child: AlertDialog(
title: Center(
child: Text(
helper.title,
style: Theme.of(context).textTheme.headlineSmall,
softWrap: true,
textAlign: TextAlign.center,
),
),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0),
content: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (subtitle != null)
Text(
subtitle!,
softWrap: true,
textAlign: TextAlign.center,
// This is what ListTile uses for subtitle
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
color: Theme.of(context).textTheme.bodySmall!.color,
),
),
const SizedBox(height: 12.0),
DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.rectangle,
color: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: const BorderRadius.all(Radius.circular(30.0)),
),
child: Center(
child: FittedBox(
child: DefaultTextStyle.merge(
style: const TextStyle(fontSize: 28),
child: IconTheme(
data: IconTheme.of(context).copyWith(
size: 24,
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.4),
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0),
content: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (subtitle != null)
Text(
subtitle,
softWrap: true,
textAlign: TextAlign.center,
// This is what ListTile uses for subtitle
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
color: Theme.of(context).textTheme.bodySmall!.color,
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0, vertical: 8.0),
child: buildCodeView(ref),
),
const SizedBox(height: 12.0),
DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.rectangle,
color: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: const BorderRadius.all(Radius.circular(30.0)),
),
child: Center(
child: FittedBox(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0, vertical: 8.0),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconTheme(
data: IconTheme.of(context).copyWith(size: 24),
child: helper.buildCodeIcon(),
),
const SizedBox(width: 8.0),
DefaultTextStyle.merge(
style: const TextStyle(fontSize: 28),
child: helper.buildCodeLabel(),
),
],
),
),
),
),
),
),
],
),
actionsPadding: const EdgeInsets.symmetric(vertical: 10.0),
actions: [
Center(
child: FittedBox(
alignment: Alignment.center,
child: Row(children: _buildActions(context, helper)),
),
)
],
),
actionsPadding: const EdgeInsets.symmetric(vertical: 10.0),
actions: [
Center(
child: FittedBox(
alignment: Alignment.center,
child: Row(children: _buildActions(context, ref)),
),
)
],
),
),
);
},
);
}
}

View File

@ -0,0 +1,155 @@
/*
* Copyright (C) 2022 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/models.dart';
import '../../app/shortcuts.dart';
import '../../app/state.dart';
import '../../core/state.dart';
import '../../widgets/circle_timer.dart';
import '../../widgets/custom_icons.dart';
import '../models.dart';
import '../state.dart';
import 'actions.dart';
/// Support class for presenting an OATH account.
class AccountHelper {
final BuildContext _context;
final WidgetRef _ref;
final OathCredential credential;
final OathCode? code;
final bool expired;
const AccountHelper._(
this._context, this._ref, this.credential, this.code, this.expired);
factory AccountHelper(
BuildContext context, WidgetRef ref, OathCredential credential) {
final code = ref.watch(codeProvider(credential));
final expired = code == null ||
(credential.oathType == OathType.totp &&
ref.watch(expiredProvider(code.validTo)));
return AccountHelper._(context, ref, credential, code, expired);
}
String get title => credential.issuer ?? credential.name;
String? get subtitle => credential.issuer != null ? credential.name : null;
List<MenuAction> buildActions() => _ref
.watch(currentDeviceDataProvider)
.maybeWhen(
data: (data) {
final manual =
credential.touchRequired || credential.oathType == OathType.hotp;
final ready = expired || credential.oathType == OathType.hotp;
final pinned = _ref.watch(favoritesProvider).contains(credential.id);
final appLocalizations = AppLocalizations.of(_context)!;
final shortcut = Platform.isMacOS ? '\u2318 C' : 'Ctrl+C';
return [
MenuAction(
text: appLocalizations.oath_copy_to_clipboard,
icon: const Icon(Icons.copy),
intent: code == null || expired ? null : const CopyIntent(),
trailing: shortcut,
),
if (manual)
MenuAction(
text: appLocalizations.oath_calculate,
icon: const Icon(Icons.refresh),
intent: ready ? const CalculateIntent() : null,
),
MenuAction(
text: pinned
? appLocalizations.oath_unpin_account
: appLocalizations.oath_pin_account,
icon: pinned
? pushPinStrokeIcon
: const Icon(Icons.push_pin_outlined),
intent: const TogglePinIntent(),
),
if (data.info.version.isAtLeast(5, 3))
MenuAction(
icon: const Icon(Icons.edit_outlined),
text: appLocalizations.oath_rename_account,
intent: const EditIntent(),
),
MenuAction(
text: appLocalizations.oath_delete_account,
icon: const Icon(Icons.delete_outline),
intent: const DeleteIntent(),
),
];
},
orElse: () => [],
);
Widget buildCodeIcon() => AnimatedSize(
alignment: Alignment.centerRight,
duration: const Duration(milliseconds: 100),
child: Opacity(
opacity: 0.4,
child: (credential.oathType == OathType.hotp
? (expired ? const Icon(Icons.refresh) : null)
: (expired || code == null
? (credential.touchRequired
? const Icon(Icons.touch_app)
: null)
: Builder(builder: (context) {
return SizedBox.square(
dimension: (IconTheme.of(context).size ?? 18) * 0.8,
child: CircleTimer(
code!.validFrom * 1000,
code!.validTo * 1000,
),
);
}))) ??
const SizedBox(),
),
);
String _formatCode(OathCode? code) {
final 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)}';
}
}
Widget buildCodeLabel() => Opacity(
opacity: expired ? 0.4 : 1.0,
child: Text(
_formatCode(code),
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
//fontWeight: FontWeight.w400,
),
textHeightBehavior: TextHeightBehavior(
// This helps with vertical centering on desktop
applyHeightToFirstAscent: !isDesktop,
),
),
);
}

View File

@ -1,260 +0,0 @@
/*
* Copyright (C) 2022 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../exception/cancellation_exception.dart';
import '../../widgets/circle_timer.dart';
import '../../widgets/custom_icons.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
String get title => credential.issuer ?? credential.name;
@protected
String? get subtitle => credential.issuer != null ? credential.name : null;
@protected
OathCode? getCode(WidgetRef ref) => ref.watch(codeProvider(credential));
@protected
bool isValid(WidgetRef ref) =>
ref.watch(credentialsProvider)?.any((c) => credential.id == c.id) ??
false;
@protected
String formatCode(OathCode? code) {
final 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)}';
}
}
@protected
bool isExpired(OathCode? code, WidgetRef 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 {
final node = ref.read(currentDeviceProvider)!;
return await ref
.read(credentialListProvider(node.path).notifier)
.calculate(credential);
}
@protected
void copyToClipboard(
AppClipboard clipboard, BuildContext context, OathCode? code) {
if (code != null) {
clipboard.setText(code.value, isSensitive: true);
if (!clipboard.platformGivesFeedback()) {
showMessage(
context, AppLocalizations.of(context)!.oath_copied_to_clipboard);
}
}
}
@protected
Future<OathCredential?> renameCredential(
BuildContext context, WidgetRef ref) async {
final node = ref.read(currentDeviceProvider)!;
final credentials = ref.read(credentialsProvider);
return await showBlurDialog(
context: context,
builder: (context) => RenameAccountDialog(node, credential, credentials),
);
}
@protected
Future<bool> deleteCredential(BuildContext context, WidgetRef ref) async {
final node = ref.read(currentDeviceProvider)!;
return await showBlurDialog(
context: context,
builder: (context) => DeleteAccountDialog(node, credential),
) ??
false;
}
@protected
List<MenuAction> buildActions(BuildContext context, WidgetRef ref) =>
ref.watch(currentDeviceDataProvider).maybeWhen(
data: (data) {
final code = getCode(ref);
final expired = isExpired(code, ref);
final manual = credential.touchRequired ||
credential.oathType == OathType.hotp;
final ready = expired || credential.oathType == OathType.hotp;
final pinned = isPinned(ref);
final shortcut = Platform.isMacOS ? '\u2318 C' : 'Ctrl+C';
return [
MenuAction(
text: AppLocalizations.of(context)!.oath_copy_to_clipboard,
icon: const Icon(Icons.copy),
action: code == null || expired
? null
: (context) {
var clipboard = ref.read(clipboardProvider);
clipboard.setText(code.value, isSensitive: true);
if (!clipboard.platformGivesFeedback()) {
showMessage(
context,
AppLocalizations.of(context)!
.oath_copied_to_clipboard);
}
},
trailing: shortcut,
),
if (manual)
MenuAction(
text: AppLocalizations.of(context)!.oath_calculate,
icon: const Icon(Icons.refresh),
action: ready
? (context) async {
try {
await calculateCode(context, ref);
} on CancellationException catch (_) {
// ignored
}
}
: null,
),
MenuAction(
text: pinned
? AppLocalizations.of(context)!.oath_unpin_account
: AppLocalizations.of(context)!.oath_pin_account,
icon: pinned
? pushPinStrokeIcon
: const Icon(Icons.push_pin_outlined),
action: (context) {
ref
.read(favoritesProvider.notifier)
.toggleFavorite(credential.id);
},
),
if (data.info.version.isAtLeast(5, 3))
MenuAction(
icon: const Icon(Icons.edit_outlined),
text: AppLocalizations.of(context)!.oath_rename_account,
action: (context) async {
await renameCredential(context, ref);
},
),
MenuAction(
text: AppLocalizations.of(context)!.oath_delete_account,
icon: const Icon(Icons.delete_outline),
action: (context) async {
await deleteCredential(context, ref);
},
),
];
},
orElse: () => [],
);
@protected
Widget buildCodeView(WidgetRef ref) {
final code = getCode(ref);
final expired = isExpired(code, ref);
return AnimatedSize(
alignment: Alignment.centerRight,
duration: const Duration(milliseconds: 100),
child: Builder(builder: (context) {
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: code == null
? [
Icon(
credential.oathType == OathType.hotp
? Icons.refresh
: Icons.touch_app,
),
const Text(''),
]
: [
if (credential.oathType == OathType.totp) ...[
...expired
? [
if (credential.touchRequired) ...[
const Icon(Icons.touch_app),
const SizedBox(width: 8.0),
]
]
: [
SizedBox.square(
dimension:
(IconTheme.of(context).size ?? 18) * 0.8,
child: CircleTimer(
code.validFrom * 1000,
code.validTo * 1000,
),
),
const SizedBox(width: 8.0),
],
],
Opacity(
opacity: expired ? 0.4 : 1.0,
child: Text(
formatCode(code),
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
//fontWeight: FontWeight.w400,
),
textHeightBehavior: const TextHeightBehavior(
// This helps with vertical centering
applyHeightToFirstAscent: false,
),
),
),
],
);
}),
);
}
}

View File

@ -14,8 +14,6 @@
* limitations under the License.
*/
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -24,13 +22,14 @@ import '../../app/message.dart';
import '../../app/shortcuts.dart';
import '../../app/state.dart';
import '../../core/state.dart';
import '../../exception/cancellation_exception.dart';
import '../../widgets/circle_timer.dart';
import '../../widgets/menu_list_tile.dart';
import '../models.dart';
import '../state.dart';
import 'account_dialog.dart';
import 'account_mixin.dart';
import 'account_helper.dart';
import 'actions.dart';
import 'delete_account_dialog.dart';
import 'rename_account_dialog.dart';
class AccountView extends ConsumerStatefulWidget {
final OathCredential credential;
@ -40,8 +39,7 @@ class AccountView extends ConsumerStatefulWidget {
ConsumerState<ConsumerStatefulWidget> createState() => _AccountViewState();
}
class _AccountViewState extends ConsumerState<AccountView> with AccountMixin {
@override
class _AccountViewState extends ConsumerState<AccountView> {
OathCredential get credential => widget.credential;
final _focusNode = FocusNode();
@ -75,20 +73,24 @@ class _AccountViewState extends ConsumerState<AccountView> with AccountMixin {
Colors.grey[shade],
Colors.blueGrey[shade],
];
final label = credential.issuer != null
? '${credential.issuer} (${credential.name})'
: credential.name;
return colors[label.hashCode % colors.length]!;
}
List<PopupMenuItem> _buildPopupMenu(BuildContext context, WidgetRef ref) {
return buildActions(context, ref).map((e) {
final action = e.action;
List<PopupMenuItem> _buildPopupMenu(
BuildContext context, AccountHelper helper) {
return helper.buildActions().map((e) {
final intent = e.intent;
return buildMenuItem(
leading: e.icon,
title: Text(e.text),
action: action != null
action: intent != null
? () {
ref.read(withContextProvider)((context) async {
action.call(context);
});
Actions.invoke(context, intent);
}
: null,
trailing: e.trailing,
@ -98,175 +100,144 @@ class _AccountViewState extends ConsumerState<AccountView> with AccountMixin {
@override
Widget build(BuildContext context) {
final code = getCode(ref);
final expired = code == null ||
(credential.oathType == OathType.totp &&
ref.watch(expiredProvider(code.validTo)));
final calculateReady = code == null ||
credential.oathType == OathType.hotp ||
(credential.touchRequired && expired);
Future<void> triggerCopy() async {
try {
final withContext = ref.read(withContextProvider);
await withContext(
(context) async {
OathCode? code = calculateReady
? await calculateCode(
context,
ref,
)
: getCode(ref);
await withContext((context) async =>
copyToClipboard(ref.watch(clipboardProvider), context, code));
},
);
} on CancellationException catch (_) {
// ignored
}
}
final theme = Theme.of(context);
final darkMode = theme.brightness == Brightness.dark;
return GestureDetector(
onSecondaryTapDown: (details) {
showMenu(
context: context,
position: RelativeRect.fromLTRB(
details.globalPosition.dx,
details.globalPosition.dy,
details.globalPosition.dx,
0,
),
items: _buildPopupMenu(context, ref),
);
return registerOathActions(
credential,
ref: ref,
actions: {
OpenIntent: CallbackAction<OpenIntent>(onInvoke: (_) async {
await showBlurDialog(
context: context,
builder: (context) => AccountDialog(credential),
);
return null;
}),
EditIntent: CallbackAction<EditIntent>(onInvoke: (_) async {
final node = ref.read(currentDeviceProvider)!;
final credentials = ref.read(credentialsProvider);
return await ref.read(withContextProvider)(
(context) async => await showBlurDialog(
context: context,
builder: (context) =>
RenameAccountDialog(node, credential, credentials),
));
}),
DeleteIntent: CallbackAction<DeleteIntent>(onInvoke: (_) async {
final node = ref.read(currentDeviceProvider)!;
return await ref.read(withContextProvider)((context) async =>
await showBlurDialog(
context: context,
builder: (context) => DeleteAccountDialog(node, credential),
) ??
false);
}),
},
child: Actions(
actions: {
CopyIntent: CallbackAction<CopyIntent>(onInvoke: (_) async {
await triggerCopy();
return null;
}),
OpenIntent: CallbackAction<OpenIntent>(onInvoke: (_) async {
await showBlurDialog(
builder: (context) {
final helper = AccountHelper(context, ref, credential);
return GestureDetector(
onSecondaryTapDown: (details) {
showMenu(
context: context,
builder: (context) => AccountDialog(credential),
position: RelativeRect.fromLTRB(
details.globalPosition.dx,
details.globalPosition.dy,
details.globalPosition.dx,
0,
),
items: _buildPopupMenu(context, helper),
);
return null;
}),
},
child: LayoutBuilder(builder: (context, constraints) {
final showAvatar = constraints.maxWidth >= 315;
},
child: LayoutBuilder(builder: (context, constraints) {
final showAvatar = constraints.maxWidth >= 315;
return Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.enter): const OpenIntent(),
LogicalKeySet(LogicalKeyboardKey.space): const OpenIntent(),
},
child: ListTile(
focusNode: _focusNode,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
),
onTap: () {
if (isDesktop) {
final now = DateTime.now().millisecondsSinceEpoch;
if (now - _lastTap < 500) {
setState(() {
_lastTap = 0;
});
triggerCopy();
} else {
_focusNode.requestFocus();
setState(() {
_lastTap = now;
});
}
} else {
Actions.maybeInvoke<OpenIntent>(context, const OpenIntent());
}
final subtitle = helper.subtitle;
return Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.enter): const OpenIntent(),
LogicalKeySet(LogicalKeyboardKey.space): const OpenIntent(),
},
onLongPress: triggerCopy,
leading: showAvatar
? 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: 16, fontWeight: FontWeight.w300),
),
)
: null,
title: Text(
title,
overflow: TextOverflow.fade,
maxLines: 1,
softWrap: false,
),
subtitle: subtitle != null
? Text(
subtitle!,
overflow: TextOverflow.fade,
maxLines: 1,
softWrap: false,
)
: null,
trailing: Focus(
skipTraversal: true,
descendantsAreTraversable: false,
child: FilledButton.tonalIcon(
icon: AnimatedSize(
alignment: Alignment.centerRight,
duration: const Duration(milliseconds: 100),
child: Opacity(
opacity: 0.4,
child: (credential.oathType == OathType.hotp
? (expired ? const Icon(Icons.refresh) : null)
: (expired
? (credential.touchRequired
? const Icon(Icons.touch_app)
: null)
: SizedBox.square(
dimension:
(IconTheme.of(context).size ?? 18) *
0.8,
child: CircleTimer(
code.validFrom * 1000,
code.validTo * 1000,
),
))) ??
const SizedBox(),
),
),
label: Opacity(
opacity: expired ? 0.4 : 1.0,
child: Text(
formatCode(code),
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
//fontWeight: FontWeight.w400,
),
textHeightBehavior: TextHeightBehavior(
// This helps with vertical centering on desktop
applyHeightToFirstAscent: !isDesktop,
),
),
),
onPressed: () {
child: ListTile(
focusNode: _focusNode,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
),
onTap: () {
if (isDesktop) {
final now = DateTime.now().millisecondsSinceEpoch;
if (now - _lastTap < 500) {
setState(() {
_lastTap = 0;
});
//triggerCopy();
Actions.maybeInvoke(context, const CopyIntent());
} else {
_focusNode.requestFocus();
setState(() {
_lastTap = now;
});
}
} else {
Actions.maybeInvoke<OpenIntent>(
context, const OpenIntent());
},
}
},
onLongPress: () {
Actions.maybeInvoke(context, const CopyIntent());
},
leading: showAvatar
? 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: 16, fontWeight: FontWeight.w300),
),
)
: null,
title: Text(
helper.title,
overflow: TextOverflow.fade,
maxLines: 1,
softWrap: false,
),
subtitle: subtitle != null
? Text(
subtitle,
overflow: TextOverflow.fade,
maxLines: 1,
softWrap: false,
)
: null,
trailing: Focus(
skipTraversal: true,
descendantsAreTraversable: false,
child: helper.code != null
? FilledButton.tonalIcon(
icon: helper.buildCodeIcon(),
label: helper.buildCodeLabel(),
onPressed: () {
Actions.maybeInvoke<OpenIntent>(
context, const OpenIntent());
},
)
: FilledButton.tonal(
onPressed: () {
Actions.maybeInvoke<OpenIntent>(
context, const OpenIntent());
},
child: helper.buildCodeIcon()),
),
),
),
);
}),
),
);
}),
);
},
);
}
}

69
lib/oath/views/actions.dart Executable file
View File

@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../../app/message.dart';
import '../../app/shortcuts.dart';
import '../../app/state.dart';
import '../../exception/cancellation_exception.dart';
import '../models.dart';
import '../state.dart';
class CalculateIntent extends Intent {
const CalculateIntent();
}
class TogglePinIntent extends Intent {
const TogglePinIntent();
}
Future<OathCode?> _calculateCode(
OathCredential credential, WidgetRef ref) async {
final node = ref.read(currentDeviceProvider)!;
try {
return await ref
.read(credentialListProvider(node.path).notifier)
.calculate(credential);
} on CancellationException catch (_) {
return null;
}
}
Widget registerOathActions(
OathCredential credential, {
required WidgetRef ref,
required Widget Function(BuildContext context) builder,
Map<Type, Action<Intent>> actions = const {},
}) =>
Actions(
actions: {
CalculateIntent: CallbackAction<CalculateIntent>(onInvoke: (_) {
return _calculateCode(credential, ref);
}),
CopyIntent: CallbackAction<CopyIntent>(onInvoke: (_) async {
var code = ref.read(codeProvider(credential));
if (code == null ||
(credential.oathType == OathType.totp &&
ref.read(expiredProvider(code.validTo)))) {
code = await _calculateCode(credential, ref);
}
if (code != null) {
final clipboard = ref.watch(clipboardProvider);
await clipboard.setText(code.value, isSensitive: true);
if (!clipboard.platformGivesFeedback()) {
await ref.read(withContextProvider)((context) async {
showMessage(context,
AppLocalizations.of(context)!.oath_copied_to_clipboard);
});
}
}
return code;
}),
TogglePinIntent: CallbackAction<TogglePinIntent>(onInvoke: (_) {
ref.read(favoritesProvider.notifier).toggleFavorite(credential.id);
return null;
}),
...actions,
},
child: Builder(builder: builder),
);

View File

@ -17,8 +17,8 @@
import 'dart:math';
import '../../widgets/utf8_utils.dart';
import '../models.dart';
import '../../core/models.dart';
import '../models.dart';
/// Calculates the available space for issuer and account name.
///