mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-12-23 10:11:52 +03:00
Refactor OATH views and actions.
This commit is contained in:
parent
15cdf0d62c
commit
2d887593bb
@ -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(),
|
||||
|
@ -124,7 +124,7 @@ class MenuAction with _$MenuAction {
|
||||
required String text,
|
||||
required Widget icon,
|
||||
String? trailing,
|
||||
void Function(BuildContext context)? action,
|
||||
Intent? intent,
|
||||
}) = _MenuAction;
|
||||
}
|
||||
|
||||
|
@ -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 =>
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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)),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
155
lib/oath/views/account_helper.dart
Executable file
155
lib/oath/views/account_helper.dart
Executable 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
@ -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
69
lib/oath/views/actions.dart
Executable 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),
|
||||
);
|
@ -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.
|
||||
///
|
||||
|
Loading…
Reference in New Issue
Block a user