mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-12-22 17:51:29 +03:00
Merge PR #28.
This commit is contained in:
commit
a8c14637f7
@ -18,3 +18,8 @@ class Version with _$Version {
|
||||
return '$major.$minor.$patch';
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class Pair<T1, T2> with _$Pair<T1, T2> {
|
||||
factory Pair(T1 first, T2 second) = _Pair<T1, T2>;
|
||||
}
|
||||
|
@ -168,3 +168,147 @@ abstract class _Version extends Version {
|
||||
_$VersionCopyWith<_Version> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$PairTearOff {
|
||||
const _$PairTearOff();
|
||||
|
||||
_Pair<T1, T2> call<T1, T2>(T1 first, T2 second) {
|
||||
return _Pair<T1, T2>(
|
||||
first,
|
||||
second,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
const $Pair = _$PairTearOff();
|
||||
|
||||
/// @nodoc
|
||||
mixin _$Pair<T1, T2> {
|
||||
T1 get first => throw _privateConstructorUsedError;
|
||||
T2 get second => throw _privateConstructorUsedError;
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
$PairCopyWith<T1, T2, Pair<T1, T2>> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $PairCopyWith<T1, T2, $Res> {
|
||||
factory $PairCopyWith(Pair<T1, T2> value, $Res Function(Pair<T1, T2>) then) =
|
||||
_$PairCopyWithImpl<T1, T2, $Res>;
|
||||
$Res call({T1 first, T2 second});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$PairCopyWithImpl<T1, T2, $Res> implements $PairCopyWith<T1, T2, $Res> {
|
||||
_$PairCopyWithImpl(this._value, this._then);
|
||||
|
||||
final Pair<T1, T2> _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function(Pair<T1, T2>) _then;
|
||||
|
||||
@override
|
||||
$Res call({
|
||||
Object? first = freezed,
|
||||
Object? second = freezed,
|
||||
}) {
|
||||
return _then(_value.copyWith(
|
||||
first: first == freezed
|
||||
? _value.first
|
||||
: first // ignore: cast_nullable_to_non_nullable
|
||||
as T1,
|
||||
second: second == freezed
|
||||
? _value.second
|
||||
: second // ignore: cast_nullable_to_non_nullable
|
||||
as T2,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$PairCopyWith<T1, T2, $Res>
|
||||
implements $PairCopyWith<T1, T2, $Res> {
|
||||
factory _$PairCopyWith(
|
||||
_Pair<T1, T2> value, $Res Function(_Pair<T1, T2>) then) =
|
||||
__$PairCopyWithImpl<T1, T2, $Res>;
|
||||
@override
|
||||
$Res call({T1 first, T2 second});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$PairCopyWithImpl<T1, T2, $Res> extends _$PairCopyWithImpl<T1, T2, $Res>
|
||||
implements _$PairCopyWith<T1, T2, $Res> {
|
||||
__$PairCopyWithImpl(_Pair<T1, T2> _value, $Res Function(_Pair<T1, T2>) _then)
|
||||
: super(_value, (v) => _then(v as _Pair<T1, T2>));
|
||||
|
||||
@override
|
||||
_Pair<T1, T2> get _value => super._value as _Pair<T1, T2>;
|
||||
|
||||
@override
|
||||
$Res call({
|
||||
Object? first = freezed,
|
||||
Object? second = freezed,
|
||||
}) {
|
||||
return _then(_Pair<T1, T2>(
|
||||
first == freezed
|
||||
? _value.first
|
||||
: first // ignore: cast_nullable_to_non_nullable
|
||||
as T1,
|
||||
second == freezed
|
||||
? _value.second
|
||||
: second // ignore: cast_nullable_to_non_nullable
|
||||
as T2,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
class _$_Pair<T1, T2> implements _Pair<T1, T2> {
|
||||
_$_Pair(this.first, this.second);
|
||||
|
||||
@override
|
||||
final T1 first;
|
||||
@override
|
||||
final T2 second;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Pair<$T1, $T2>(first: $first, second: $second)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(dynamic other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _Pair<T1, T2> &&
|
||||
const DeepCollectionEquality().equals(other.first, first) &&
|
||||
const DeepCollectionEquality().equals(other.second, second));
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
const DeepCollectionEquality().hash(first),
|
||||
const DeepCollectionEquality().hash(second));
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
@override
|
||||
_$PairCopyWith<T1, T2, _Pair<T1, T2>> get copyWith =>
|
||||
__$PairCopyWithImpl<T1, T2, _Pair<T1, T2>>(this, _$identity);
|
||||
}
|
||||
|
||||
abstract class _Pair<T1, T2> implements Pair<T1, T2> {
|
||||
factory _Pair(T1 first, T2 second) = _$_Pair<T1, T2>;
|
||||
|
||||
@override
|
||||
T1 get first;
|
||||
@override
|
||||
T2 get second;
|
||||
@override
|
||||
@JsonKey(ignore: true)
|
||||
_$PairCopyWith<T1, T2, _Pair<T1, T2>> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
@ -3,6 +3,12 @@ import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
part 'models.freezed.dart';
|
||||
part 'models.g.dart';
|
||||
|
||||
const defaultPeriod = 30;
|
||||
const defaultDigits = 6;
|
||||
const defaultCounter = 0;
|
||||
const defaultOathType = OathType.totp;
|
||||
const defaultHashAlgorithm = HashAlgorithm.sha1;
|
||||
|
||||
enum HashAlgorithm {
|
||||
@JsonValue(0x01)
|
||||
sha1,
|
||||
@ -12,10 +18,6 @@ enum HashAlgorithm {
|
||||
sha512,
|
||||
}
|
||||
|
||||
extension on HashAlgorithm {
|
||||
String get name => toString().split('.').last.toUpperCase();
|
||||
}
|
||||
|
||||
enum OathType {
|
||||
@JsonValue(0x10)
|
||||
hotp,
|
||||
@ -23,10 +25,6 @@ enum OathType {
|
||||
totp,
|
||||
}
|
||||
|
||||
extension on OathType {
|
||||
String get name => toString().split('.').last.toUpperCase();
|
||||
}
|
||||
|
||||
@freezed
|
||||
class OathCredential with _$OathCredential {
|
||||
factory OathCredential(
|
||||
@ -71,11 +69,11 @@ class CredentialData with _$CredentialData {
|
||||
String? issuer,
|
||||
required String name,
|
||||
required String secret,
|
||||
@Default(OathType.totp) OathType oathType,
|
||||
@Default(HashAlgorithm.sha1) HashAlgorithm hashAlgorithm,
|
||||
@Default(6) int digits,
|
||||
@Default(30) int period,
|
||||
@Default(0) int counter,
|
||||
@Default(defaultOathType) OathType oathType,
|
||||
@Default(defaultHashAlgorithm) HashAlgorithm hashAlgorithm,
|
||||
@Default(defaultDigits) int digits,
|
||||
@Default(defaultPeriod) int period,
|
||||
@Default(defaultCounter) int counter,
|
||||
}) = _CredentialData;
|
||||
|
||||
factory CredentialData.fromJson(Map<String, dynamic> json) =>
|
||||
|
@ -837,11 +837,11 @@ class _$CredentialDataTearOff {
|
||||
{String? issuer,
|
||||
required String name,
|
||||
required String secret,
|
||||
OathType oathType = OathType.totp,
|
||||
HashAlgorithm hashAlgorithm = HashAlgorithm.sha1,
|
||||
int digits = 6,
|
||||
int period = 30,
|
||||
int counter = 0}) {
|
||||
OathType oathType = defaultOathType,
|
||||
HashAlgorithm hashAlgorithm = defaultHashAlgorithm,
|
||||
int digits = defaultDigits,
|
||||
int period = defaultPeriod,
|
||||
int counter = defaultCounter}) {
|
||||
return _CredentialData(
|
||||
issuer: issuer,
|
||||
name: name,
|
||||
@ -1036,11 +1036,11 @@ class _$_CredentialData extends _CredentialData {
|
||||
{this.issuer,
|
||||
required this.name,
|
||||
required this.secret,
|
||||
this.oathType = OathType.totp,
|
||||
this.hashAlgorithm = HashAlgorithm.sha1,
|
||||
this.digits = 6,
|
||||
this.period = 30,
|
||||
this.counter = 0})
|
||||
this.oathType = defaultOathType,
|
||||
this.hashAlgorithm = defaultHashAlgorithm,
|
||||
this.digits = defaultDigits,
|
||||
this.period = defaultPeriod,
|
||||
this.counter = defaultCounter})
|
||||
: super._();
|
||||
|
||||
factory _$_CredentialData.fromJson(Map<String, dynamic> json) =>
|
||||
|
@ -65,13 +65,13 @@ _$_CredentialData _$$_CredentialDataFromJson(Map<String, dynamic> json) =>
|
||||
name: json['name'] as String,
|
||||
secret: json['secret'] as String,
|
||||
oathType: $enumDecodeNullable(_$OathTypeEnumMap, json['oath_type']) ??
|
||||
OathType.totp,
|
||||
defaultOathType,
|
||||
hashAlgorithm:
|
||||
$enumDecodeNullable(_$HashAlgorithmEnumMap, json['hash_algorithm']) ??
|
||||
HashAlgorithm.sha1,
|
||||
digits: json['digits'] as int? ?? 6,
|
||||
period: json['period'] as int? ?? 30,
|
||||
counter: json['counter'] as int? ?? 0,
|
||||
defaultHashAlgorithm,
|
||||
digits: json['digits'] as int? ?? defaultDigits,
|
||||
period: json['period'] as int? ?? defaultPeriod,
|
||||
counter: json['counter'] as int? ?? defaultCounter,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$_CredentialDataToJson(_$_CredentialData instance) =>
|
||||
|
@ -1,26 +1,267 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:yubico_authenticator/oath/models.dart';
|
||||
|
||||
import '../../app/state.dart';
|
||||
import '../../app/models.dart';
|
||||
import '../state.dart';
|
||||
import 'utils.dart';
|
||||
|
||||
final _secretFormatterPattern =
|
||||
RegExp('[abcdefghijklmnopqrstuvwxyz234567 ]', caseSensitive: false);
|
||||
|
||||
class AddAccountForm extends StatefulWidget {
|
||||
final Function(CredentialData, bool) onSubmit;
|
||||
const AddAccountForm({Key? key, required this.onSubmit}) : super(key: key);
|
||||
|
||||
class OathAddAccountPage extends ConsumerStatefulWidget {
|
||||
const OathAddAccountPage({required this.device, Key? key}) : super(key: key);
|
||||
final DeviceNode device;
|
||||
@override
|
||||
OathAddAccountPageState createState() => OathAddAccountPageState();
|
||||
State<StatefulWidget> createState() => _AddAccountFormState();
|
||||
}
|
||||
|
||||
class OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
class _AddAccountFormState extends State<AddAccountForm> {
|
||||
String _issuer = '';
|
||||
String _account = '';
|
||||
String _secret = '';
|
||||
bool _touch = false;
|
||||
bool _advanced = false;
|
||||
OathType _oathType = defaultOathType;
|
||||
HashAlgorithm _hashAlgorithm = defaultHashAlgorithm;
|
||||
int _period = defaultPeriod;
|
||||
int _digits = defaultDigits;
|
||||
bool _validateSecretLength = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final remaining = getRemainingKeySpace(
|
||||
oathType: _oathType,
|
||||
period: _period,
|
||||
issuer: _issuer,
|
||||
name: _account,
|
||||
);
|
||||
final issuerRemaining = remaining.first;
|
||||
final nameRemaining = remaining.second;
|
||||
|
||||
final secretLengthValid = _secret.length * 5 % 8 < 5;
|
||||
final isValid = _account.isNotEmpty && _secret.isNotEmpty && _period > 0;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
enabled: issuerRemaining > 0,
|
||||
maxLength: max(issuerRemaining, 1),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Issuer (optional)',
|
||||
helperText:
|
||||
'', // Prevents dialog resizing when enabled = false
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_issuer = value.trim();
|
||||
});
|
||||
},
|
||||
),
|
||||
TextField(
|
||||
maxLength: nameRemaining,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Account name',
|
||||
helperText:
|
||||
'', // Prevents dialog resizing when enabled = false
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_account = value.trim();
|
||||
});
|
||||
},
|
||||
),
|
||||
TextField(
|
||||
inputFormatters: <TextInputFormatter>[
|
||||
FilteringTextInputFormatter.allow(_secretFormatterPattern)
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Secret key',
|
||||
errorText: _validateSecretLength && !secretLengthValid
|
||||
? 'Invalid length'
|
||||
: null),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_secret = value.replaceAll(' ', '');
|
||||
_validateSecretLength = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: const Text('Require touch'),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
value: _touch,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_touch = value ?? false;
|
||||
});
|
||||
},
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: const Text('Show advanced settings'),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
value: _advanced,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_advanced = value ?? false;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (_advanced)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<OathType>(
|
||||
decoration: const InputDecoration(labelText: 'Type'),
|
||||
value: _oathType,
|
||||
items: OathType.values
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text(e.name.toUpperCase()),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (type) {
|
||||
setState(() {
|
||||
_oathType = type ?? OathType.totp;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 8.0,
|
||||
),
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<HashAlgorithm>(
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Algorithm'),
|
||||
value: _hashAlgorithm,
|
||||
items: HashAlgorithm.values
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text(e.name.toUpperCase()),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (type) {
|
||||
setState(() {
|
||||
_hashAlgorithm = type ?? HashAlgorithm.sha1;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_oathType == OathType.totp)
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
initialValue: _period > 0 ? _period.toString() : '',
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: <TextInputFormatter>[
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
contentPadding:
|
||||
// Manual alignment to match digits-dropdown.
|
||||
const EdgeInsets.fromLTRB(0, 12, 0, 15),
|
||||
labelText: 'Period',
|
||||
errorText: _period > 0
|
||||
? null
|
||||
: 'Must be a positive number',
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_period = int.tryParse(value) ?? -1;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
if (_oathType == OathType.totp)
|
||||
const SizedBox(
|
||||
width: 8.0,
|
||||
),
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<int>(
|
||||
decoration: const InputDecoration(labelText: 'Digits'),
|
||||
value: _digits,
|
||||
items: [6, 7, 8]
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text(e.toString()),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_digits = value ?? defaultDigits;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
alignment: Alignment.centerRight,
|
||||
child: ElevatedButton(
|
||||
onPressed: isValid
|
||||
? () {
|
||||
if (secretLengthValid) {
|
||||
widget.onSubmit(
|
||||
CredentialData(
|
||||
issuer: _issuer.isEmpty ? null : _issuer,
|
||||
name: _account,
|
||||
secret: _secret,
|
||||
oathType: _oathType,
|
||||
hashAlgorithm: _hashAlgorithm,
|
||||
digits: _digits,
|
||||
period: _period,
|
||||
),
|
||||
_touch,
|
||||
);
|
||||
} else {
|
||||
setState(() {
|
||||
_validateSecretLength = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
: null,
|
||||
child: const Text('Add account'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OathAddAccountPage extends ConsumerWidget {
|
||||
const OathAddAccountPage({required this.device, Key? key}) : super(key: key);
|
||||
final DeviceNode device;
|
||||
|
||||
@override
|
||||
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) {
|
||||
//TODO: This can probably be checked better to make sure it's the main page.
|
||||
@ -28,77 +269,16 @@ class OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Add account'),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Issuer',
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_issuer = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Account name *',
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_account = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Secret key *',
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_secret = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: CheckboxListTile(
|
||||
title: const Text('Require touch'),
|
||||
checkColor: Colors.white,
|
||||
value: _touch,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_touch = value ?? false;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
final cred = CredentialData(
|
||||
issuer: _issuer.isEmpty ? null : _issuer,
|
||||
name: _account,
|
||||
secret: _secret);
|
||||
appBar: AppBar(
|
||||
title: const Text('Add account'),
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
AddAccountForm(
|
||||
onSubmit: (cred, requireTouch) {
|
||||
ref
|
||||
.read(credentialListProvider(widget.device.path).notifier)
|
||||
.addAccount(cred.toUri(), requireTouch: _touch);
|
||||
.read(credentialListProvider(device.path).notifier)
|
||||
.addAccount(cred.toUri(), requireTouch: requireTouch);
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
@ -107,11 +287,8 @@ class OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Submit'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
)
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ import '../models.dart';
|
||||
import '../state.dart';
|
||||
import '../../app/models.dart';
|
||||
import '../../app/state.dart';
|
||||
import 'utils.dart';
|
||||
|
||||
class RenameAccountDialog extends ConsumerStatefulWidget {
|
||||
final DeviceNode device;
|
||||
@ -18,16 +19,15 @@ class RenameAccountDialog extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
|
||||
late TextEditingController _issuerController;
|
||||
late TextEditingController _nameController;
|
||||
late String _issuer;
|
||||
late String _account;
|
||||
_RenameAccountDialogState();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_issuerController =
|
||||
TextEditingController(text: widget.credential.issuer ?? '');
|
||||
_nameController = TextEditingController(text: widget.credential.name);
|
||||
_issuer = widget.credential.issuer ?? '';
|
||||
_account = widget.credential.name;
|
||||
}
|
||||
|
||||
@override
|
||||
@ -43,18 +43,15 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
|
||||
? '${credential.issuer} (${credential.name})'
|
||||
: credential.name;
|
||||
|
||||
int remaining = 64; // 64 bytes are shared between issuer and name.
|
||||
if (credential.oathType == OathType.totp && credential.period != 30) {
|
||||
// Non-standard periods are stored as part of this data, as a "D/"- prefix.
|
||||
remaining -= '${credential.period}/'.length;
|
||||
}
|
||||
if (_issuerController.text.isNotEmpty) {
|
||||
// Issuer is separated from name with a ":", if present.
|
||||
remaining -= 1;
|
||||
}
|
||||
final issuerRemaining = remaining - _nameController.text.length;
|
||||
final nameRemaining = remaining - _issuerController.text.length;
|
||||
final isValid = _nameController.text.trim().isNotEmpty;
|
||||
final remaining = getRemainingKeySpace(
|
||||
oathType: credential.oathType,
|
||||
period: credential.period,
|
||||
issuer: _issuer,
|
||||
name: _account,
|
||||
);
|
||||
final issuerRemaining = remaining.first;
|
||||
final nameRemaining = remaining.second;
|
||||
final isValid = _account.isNotEmpty;
|
||||
|
||||
return AlertDialog(
|
||||
title: Text('Rename $label?'),
|
||||
@ -63,29 +60,32 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
|
||||
children: [
|
||||
const Text(
|
||||
'This will change how the account is displayed in the list.'),
|
||||
TextField(
|
||||
controller: _issuerController,
|
||||
TextFormField(
|
||||
initialValue: _issuer,
|
||||
enabled: issuerRemaining > 0,
|
||||
maxLength: issuerRemaining > 0 ? issuerRemaining : null,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Issuer',
|
||||
labelText: 'Issuer (optional)',
|
||||
helperText: '', // Prevents dialog resizing when enabled = false
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {}); // Update maxLength
|
||||
setState(() {
|
||||
_issuer = value.trim();
|
||||
});
|
||||
},
|
||||
),
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
enabled: nameRemaining > 0,
|
||||
maxLength: nameRemaining > 0 ? nameRemaining : null,
|
||||
TextFormField(
|
||||
initialValue: _account,
|
||||
maxLength: nameRemaining,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Account name *',
|
||||
labelText: 'Account name',
|
||||
helperText: '', // Prevents dialog resizing when enabled = false
|
||||
errorText: isValid ? null : 'Your account must have a name',
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {}); // Update maxLength, isValid
|
||||
setState(() {
|
||||
_account = value.trim();
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
@ -100,12 +100,10 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
|
||||
ElevatedButton(
|
||||
onPressed: isValid
|
||||
? () async {
|
||||
final issuer = _issuerController.text.trim();
|
||||
final name = _nameController.text.trim();
|
||||
await ref
|
||||
.read(credentialListProvider(widget.device.path).notifier)
|
||||
.renameAccount(
|
||||
credential, issuer.isNotEmpty ? issuer : null, name);
|
||||
.renameAccount(credential,
|
||||
_issuer.isNotEmpty ? _issuer : null, _account);
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
|
32
lib/oath/views/utils.dart
Executable file
32
lib/oath/views/utils.dart
Executable file
@ -0,0 +1,32 @@
|
||||
import 'dart:math';
|
||||
|
||||
import '../models.dart';
|
||||
import '../../core/models.dart';
|
||||
|
||||
/// Calculates the available space for issuer and account name.
|
||||
///
|
||||
/// Returns a [Pair] of the space available for the issuer and account name,
|
||||
/// respectively, based on the current state of the credential.
|
||||
Pair<int, int> getRemainingKeySpace(
|
||||
{required OathType oathType,
|
||||
required int period,
|
||||
required String issuer,
|
||||
required String name}) {
|
||||
int remaining = 64; // The field is 64 bytes in total.
|
||||
|
||||
if (oathType == OathType.totp && period != defaultPeriod) {
|
||||
// Non-standard TOTP periods are stored as part of this data, as a "D/"- prefix.
|
||||
remaining -= '$period/'.length;
|
||||
}
|
||||
int issuerSpace = issuer.length;
|
||||
if (issuer.isNotEmpty) {
|
||||
// Issuer is separated from name with a ":", if present.
|
||||
issuerSpace += 1;
|
||||
}
|
||||
|
||||
return Pair(
|
||||
// Always reserve at least one character for name
|
||||
remaining - 1 - max(name.length, 1),
|
||||
remaining - issuerSpace,
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue
Block a user