yubioath-flutter/lib/oath/views/add_multi_account_page.dart
2024-03-08 09:50:34 +01:00

326 lines
12 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../android/oath/state.dart';
import '../../app/logging.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../core/models.dart';
import '../../core/state.dart';
import '../../desktop/models.dart';
import '../../exception/apdu_exception.dart';
import '../../exception/cancellation_exception.dart';
import '../../widgets/responsive_dialog.dart';
import '../models.dart';
import '../state.dart';
import 'rename_account_dialog.dart';
final _log = Logger('oath.views.list_screen');
class OathAddMultiAccountPage extends ConsumerStatefulWidget {
final DevicePath? devicePath;
final OathState? state;
final List<CredentialData>? credentialsFromUri;
const OathAddMultiAccountPage(
this.devicePath, this.state, this.credentialsFromUri,
{super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() =>
_OathAddMultiAccountPageState();
}
class _OathAddMultiAccountPageState
extends ConsumerState<OathAddMultiAccountPage> {
int? _numCreds;
late Map<CredentialData, (bool, bool, bool)> _credStates;
List<OathCredential>? _credentials;
@override
void initState() {
super.initState();
_credStates = Map.fromIterable(widget.credentialsFromUri!,
value: (v) => (true, false, false));
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
if (widget.devicePath != null) {
_credentials = ref
.watch(credentialListProvider(widget.devicePath!))
?.map((e) => e.credential)
.toList();
_numCreds = ref.watch(credentialListProvider(widget.devicePath!)
.select((value) => value?.length));
}
// If the credential is not unique, make sure the checkbox is not checked
checkForDuplicates();
return ResponsiveDialog(
title: Text(l10n.s_add_accounts),
actions: [
TextButton(
onPressed: isValid() ? submit : null,
child: Text(l10n.s_save),
)
],
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Text(l10n.l_select_accounts)),
...widget.credentialsFromUri!.map(
(cred) {
final (checked, touch, unique) = _credStates[cred]!;
return CheckboxListTile(
controlAffinity: ListTileControlAffinity.leading,
secondary: Row(mainAxisSize: MainAxisSize.min, children: [
if (isTouchSupported())
Semantics(
label: l10n.s_require_touch,
child: IconButton(
tooltip: l10n.s_require_touch,
color: touch ? colorScheme.primary : null,
onPressed: unique
? () {
setState(() {
_credStates[cred] =
(checked, !touch, unique);
});
}
: null,
icon: Icon(Symbols.touch_app,
fill: touch ? 1.0 : 0.0)),
),
Semantics(
label: l10n.s_rename_account,
child: IconButton(
tooltip: l10n.s_rename_account,
onPressed: () async {
final node = ref
.read(currentDeviceDataProvider)
.valueOrNull
?.node;
final withContext = ref.read(withContextProvider);
CredentialData? renamed = await withContext(
(context) async => await showBlurDialog(
context: context,
builder: (context) => RenameAccountDialog(
devicePath: node!.path,
issuer: cred.issuer,
name: cred.name,
oathType: cred.oathType,
period: cred.period,
existing: (widget.credentialsFromUri ??
[])
.map((e) => (e.issuer, e.name))
.followedBy((_credentials ?? [])
.map((e) => (e.issuer, e.name)))
.toList(),
rename: (issuer, name) async => cred
.copyWith(issuer: issuer, name: name),
),
));
if (renamed != null) {
setState(() {
int index = widget.credentialsFromUri!.indexWhere(
(element) =>
element.name == cred.name &&
(element.issuer == cred.issuer));
widget.credentialsFromUri![index] = renamed;
_credStates.remove(cred);
_credStates[renamed] = (true, touch, true);
});
}
},
icon: IconTheme(
data: IconTheme.of(context),
child: const Icon(Symbols.edit)),
),
),
]),
title: Text(cred.issuer ?? cred.name,
overflow: TextOverflow.fade,
maxLines: 1,
softWrap: false),
value: unique && checked,
enabled: unique,
subtitle: cred.issuer != null || !unique
? Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (cred.issuer != null)
Text(cred.name,
overflow: TextOverflow.fade,
maxLines: 1,
softWrap: false),
if (!unique)
Text(
l10n.l_account_already_exists,
style: TextStyle(
color: colorScheme.error,
fontSize: 12, // TODO: use Theme
),
)
])
: null,
onChanged: (bool? value) {
setState(() {
_credStates[cred] = (value == true, touch, unique);
});
},
);
},
)
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: e,
))
.toList(),
));
}
bool isTouchSupported() => widget.state?.version.isAtLeast(4, 2) ?? true;
void checkForDuplicates() {
for (final item in _credStates.entries) {
CredentialData cred = item.key;
final (checked, touch, _) = item.value;
final unique = isUnique(cred);
_credStates[cred] = (checked && unique, touch, unique);
}
}
bool isUnique(CredentialData cred) {
String nameText = cred.name;
String? issuerText = cred.issuer ?? '';
bool ans = _credentials
?.where((element) =>
element.name == nameText &&
(element.issuer ?? '') == issuerText)
.isEmpty ??
true;
return ans;
}
bool isValid() {
if (widget.state != null) {
final credsToAdd =
_credStates.values.where((element) => element.$1).length;
final capacity = widget.state!.capacity;
return (credsToAdd > 0) &&
(capacity == null || (_numCreds! + credsToAdd <= capacity));
} else {
return true;
}
}
void submit() async {
final deviceNode = ref.watch(currentDeviceProvider);
if (isAndroid &&
(widget.devicePath == null || deviceNode?.transport == Transport.nfc)) {
var uris = <String>[];
var touchRequired = <bool>[];
// build list of uris and touch required flags for unique credentials
for (final item in _credStates.entries) {
CredentialData cred = item.key;
final (checked, touch, _) = item.value;
if (checked) {
uris.add(cred.toUri().toString());
touchRequired.add(touch);
}
}
await _addCredentials(uris: uris, touchRequired: touchRequired);
} else {
_credStates.forEach((cred, value) {
if (value.$1) {
accept(cred, value.$2);
}
});
Navigator.of(context).pop();
}
}
Future<void> _addCredentials(
{required List<String> uris, required List<bool> touchRequired}) async {
final l10n = AppLocalizations.of(context)!;
try {
await ref.read(addCredentialsToAnyProvider).call(uris, touchRequired);
if (!mounted) return;
Navigator.of(context).pop();
showMessage(context, l10n.s_account_added);
} on CancellationException catch (_) {
// ignored
} catch (e) {
_log.error('Failed to add multiple accounts', e.toString());
final String errorMessage;
if (e is ApduException) {
errorMessage = e.message;
} else {
errorMessage = e.toString();
}
showMessage(
context,
l10n.l_account_add_failed(errorMessage),
duration: const Duration(seconds: 4),
);
}
}
void accept(CredentialData cred, bool touch) async {
final l10n = AppLocalizations.of(context)!;
final devicePath = widget.devicePath;
try {
if (devicePath == null) {
assert(isAndroid, 'devicePath is only optional for Android');
await ref
.read(addCredentialToAnyProvider)
.call(cred.toUri(), requireTouch: touch);
} else {
await ref
.read(credentialListProvider(devicePath).notifier)
.addAccount(cred.toUri(), requireTouch: touch);
}
if (!mounted) return;
//Navigator.of(context).pop();
showMessage(context, l10n.s_account_added);
} on CancellationException catch (_) {
// ignored
} catch (e) {
_log.error('Failed to add account', e);
final String errorMessage;
// TODO: Make this cleaner than importing desktop specific RpcError.
if (e is RpcError) {
errorMessage = e.message;
} else if (e is ApduException) {
errorMessage = e.message;
} else {
errorMessage = e.toString();
}
showMessage(
context,
l10n.l_account_add_failed(errorMessage),
duration: const Duration(seconds: 4),
);
}
}
}