diff --git a/lib/views/add_token_manually_view/add_token_manually_view.dart b/lib/views/add_token_manually_view/add_token_manually_view.dart index 2e68c04d..9b425ae1 100644 --- a/lib/views/add_token_manually_view/add_token_manually_view.dart +++ b/lib/views/add_token_manually_view/add_token_manually_view.dart @@ -15,9 +15,9 @@ import '../../model/tokens/token.dart'; import '../../utils/identifiers.dart'; import '../../utils/logger.dart'; import '../../utils/riverpod_providers.dart'; -import 'add_token_manually_view_widgets/PageViewDotIndicator.dart'; +import 'add_token_manually_view_widgets/PageViewIndicator.dart'; import 'add_token_manually_view_widgets/labeled_dropdown_button.dart'; -import 'add_token_manually_view_widgets/link_input_field.dart'; +import 'add_token_manually_view_widgets/link_input_view.dart'; class AddTokenManuallyView extends ConsumerStatefulWidget { static const routeName = '/add_token_manually'; @@ -75,7 +75,7 @@ class _AddTokenManuallyViewState extends ConsumerState { ), body: Column( children: [ - PageViewDotIndicator( + PageViewIndicator( controller: pageController, icons: [ Icon(Icons.edit), @@ -90,88 +90,98 @@ class _AddTokenManuallyViewState extends ConsumerState { padding: const EdgeInsets.fromLTRB(16.0, 0.0, 16.0, 16.0), child: Form( child: Column( + mainAxisSize: MainAxisSize.max, children: [ - TextFormField( - key: _labelInputKey, - controller: _labelController, - autovalidateMode: _autoValidateLabel, - focusNode: _labelFieldFocus, - decoration: InputDecoration(labelText: AppLocalizations.of(context)!.name), - validator: (value) { - if (value!.isEmpty) { - return AppLocalizations.of(context)!.pleaseEnterANameForThisToken; - } - return null; - }, - ), - TextFormField( - key: _secretInputKey, - controller: _secretController, - autovalidateMode: _autoValidateSecret, - focusNode: _secretFieldFocus, - decoration: InputDecoration( - labelText: AppLocalizations.of(context)!.secretKey, - ), - validator: (value) { - if (value!.isEmpty) { - return AppLocalizations.of(context)!.pleaseEnterASecretForThisToken; - } else if ((_typeNotifier.value == TokenTypes.STEAM && Encodings.base32.isInvalidEncoding(value)) || - (_typeNotifier.value != TokenTypes.STEAM && _encodingNotifier.value.isInvalidEncoding(value))) { - return AppLocalizations.of(context)!.theSecretDoesNotFitTheCurrentEncoding; - } - return null; - }, - ), - Visibility( - visible: _typeNotifier.value != TokenTypes.STEAM, - child: LabeledDropdownButton( - label: AppLocalizations.of(context)!.encoding, - values: Encodings.values, - valueNotifier: _encodingNotifier, - ), - ), - Visibility( - visible: _typeNotifier.value != TokenTypes.STEAM, - child: LabeledDropdownButton( - label: AppLocalizations.of(context)!.algorithm, - values: Algorithms.values.reversed.toList(), - valueNotifier: _algorithmNotifier, - ), - ), - Visibility( - visible: _typeNotifier.value != TokenTypes.STEAM, - child: LabeledDropdownButton( - label: AppLocalizations.of(context)!.digits, - values: AddTokenManuallyView.allowedDigits, - valueNotifier: _digitsNotifier, - ), - ), - LabeledDropdownButton( - label: AppLocalizations.of(context)!.type, - values: List.from(TokenTypes.values)..remove(TokenTypes.PIPUSH), - valueNotifier: _typeNotifier, - ), - Visibility( - // the period is only used by TOTP tokens - visible: _typeNotifier.value == TokenTypes.TOTP, - child: LabeledDropdownButton( - label: AppLocalizations.of(context)!.period, - values: AddTokenManuallyView.allowedPeriodsTOTP, - valueNotifier: _periodNotifier, - postFix: 's' /*seconds*/, - ), - ), - Visibility( - // the period is only used by DayPassword tokens - visible: _typeNotifier.value == TokenTypes.DAYPASSWORD, - child: LabeledDropdownButton( - label: AppLocalizations.of(context)!.period, - values: AddTokenManuallyView.allowedPeriodsDayPassword, - valueNotifier: _periodDayPasswordNotifier, - postFix: 'h' /*hours*/, + Expanded( + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + key: _labelInputKey, + controller: _labelController, + autovalidateMode: _autoValidateLabel, + focusNode: _labelFieldFocus, + decoration: InputDecoration(labelText: AppLocalizations.of(context)!.name), + validator: (value) { + if (value!.isEmpty) { + return AppLocalizations.of(context)!.pleaseEnterANameForThisToken; + } + return null; + }, + ), + TextFormField( + key: _secretInputKey, + controller: _secretController, + autovalidateMode: _autoValidateSecret, + focusNode: _secretFieldFocus, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.secretKey, + ), + validator: (value) { + if (value!.isEmpty) { + return AppLocalizations.of(context)!.pleaseEnterASecretForThisToken; + } else if ((_typeNotifier.value == TokenTypes.STEAM && Encodings.base32.isInvalidEncoding(value)) || + (_typeNotifier.value != TokenTypes.STEAM && _encodingNotifier.value.isInvalidEncoding(value))) { + return AppLocalizations.of(context)!.theSecretDoesNotFitTheCurrentEncoding; + } + return null; + }, + ), + Visibility( + visible: _typeNotifier.value != TokenTypes.STEAM, + child: LabeledDropdownButton( + label: AppLocalizations.of(context)!.encoding, + values: Encodings.values, + valueNotifier: _encodingNotifier, + ), + ), + Visibility( + visible: _typeNotifier.value != TokenTypes.STEAM, + child: LabeledDropdownButton( + label: AppLocalizations.of(context)!.algorithm, + values: Algorithms.values.reversed.toList(), + valueNotifier: _algorithmNotifier, + ), + ), + Visibility( + visible: _typeNotifier.value != TokenTypes.STEAM, + child: LabeledDropdownButton( + label: AppLocalizations.of(context)!.digits, + values: AddTokenManuallyView.allowedDigits, + valueNotifier: _digitsNotifier, + ), + ), + LabeledDropdownButton( + label: AppLocalizations.of(context)!.type, + values: List.from(TokenTypes.values)..remove(TokenTypes.PIPUSH), + valueNotifier: _typeNotifier, + ), + Visibility( + // the period is only used by TOTP tokens + visible: _typeNotifier.value == TokenTypes.TOTP, + child: LabeledDropdownButton( + label: AppLocalizations.of(context)!.period, + values: AddTokenManuallyView.allowedPeriodsTOTP, + valueNotifier: _periodNotifier, + postFix: 's' /*seconds*/, + ), + ), + Visibility( + // the period is only used by DayPassword tokens + visible: _typeNotifier.value == TokenTypes.DAYPASSWORD, + child: LabeledDropdownButton( + label: AppLocalizations.of(context)!.period, + values: AddTokenManuallyView.allowedPeriodsDayPassword, + valueNotifier: _periodDayPasswordNotifier, + postFix: 'h' /*hours*/, + ), + ), + ], + ), ), ), - Expanded(child: SizedBox.shrink()), SizedBox( width: double.infinity, child: ElevatedButton( diff --git a/lib/views/add_token_manually_view/add_token_manually_view_widgets/PageViewIndicator.dart b/lib/views/add_token_manually_view/add_token_manually_view_widgets/PageViewIndicator.dart new file mode 100644 index 00000000..418ccc57 --- /dev/null +++ b/lib/views/add_token_manually_view/add_token_manually_view_widgets/PageViewIndicator.dart @@ -0,0 +1,79 @@ +/* + * privacyIDEA Authenticator + * + * Author: Frank Merkel + * + * Copyright (c) 2024 NetKnights GmbH + * + * 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 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class PageViewIndicator extends ConsumerStatefulWidget { + final PageController controller; + final List icons; + const PageViewIndicator({super.key, required this.controller, required this.icons}); + + @override + ConsumerState createState() => _PageViewDotIndicatorState(); +} + +class _PageViewDotIndicatorState extends ConsumerState { + int _currentPage = 0; + + @override + void initState() { + super.initState(); + widget.controller.addListener(() { + setState(() { + _currentPage = widget.controller.page?.round() ?? 0; + }); + }); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final widthPerIcon = constraints.maxWidth / (widget.icons.length + 1); + final space = widthPerIcon * 0.1; + final double iconWidth = widthPerIcon - space; + return Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (int i = 0; i < widget.icons.length; i++) ...[ + GestureDetector( + onTap: () { + final pageDifference = (i - _currentPage).abs(); + widget.controller.animateToPage(i, duration: Duration(milliseconds: 200 * pageDifference + 150), curve: Curves.easeInOut); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: _currentPage == i ? iconWidth * 2 : iconWidth, + decoration: BoxDecoration( + color: _currentPage == i ? Theme.of(context).primaryColor : Theme.of(context).disabledColor, + borderRadius: BorderRadius.circular(99), + ), + child: widget.icons[i], + ), + ), + if (i < widget.icons.length - 1) SizedBox(width: space), + ] + ], + ); + }, + ); + } +} diff --git a/lib/views/add_token_manually_view/add_token_manually_view_widgets/link_input_view.dart b/lib/views/add_token_manually_view/add_token_manually_view_widgets/link_input_view.dart new file mode 100644 index 00000000..4f5eb3a1 --- /dev/null +++ b/lib/views/add_token_manually_view/add_token_manually_view_widgets/link_input_view.dart @@ -0,0 +1,101 @@ +/* + * privacyIDEA Authenticator + * + * Author: Frank Merkel + * + * Copyright (c) 2024 NetKnights GmbH + * + * 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 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacyidea_authenticator/utils/riverpod_providers.dart'; + +import '../../../l10n/app_localizations.dart'; + +class LinkInputView extends ConsumerStatefulWidget { + const LinkInputView({super.key}); + + @override + ConsumerState createState() => _LinkInputViewState(); +} + +class _LinkInputViewState extends ConsumerState { + final textController = TextEditingController(); + + Future addToken(Uri link) async { + if (link.scheme != 'otpauth') { + ref.read(statusMessageProvider.notifier).state = (AppLocalizations.of(context)!.linkMustOtpAuth, ''); + return; + } + await ref.read(tokenProvider.notifier).handleLink(link); + if (!mounted) return; + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) => Column( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + children: [ + Row( + children: [ + Expanded( + child: SingleChildScrollView( + child: TextFormField( + controller: textController, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.tokenLink, + ), + keyboardType: TextInputType.url, + textInputAction: TextInputAction.done, + onFieldSubmitted: (text) => addToken(Uri.parse(text)), + validator: (value) => value != null + ? Uri.tryParse(value) == null + ? AppLocalizations.of(context)!.invalidUrl + : null + : null, + ), + ), + ), + SizedBox(width: 8), + IconButton( + icon: Icon(Icons.paste), + onPressed: () async { + ClipboardData? data = await Clipboard.getData('text/plain'); + if (data == null || data.text == null || data.text!.isEmpty) { + if (context.mounted) ref.read(statusMessageProvider.notifier).state = (AppLocalizations.of(context)!.clipboardEmpty, ''); + return; + } + setState(() => textController.text = data.text ?? ''); + }, + ), + ], + ), + Expanded(child: SizedBox()), + SizedBox( + width: double.infinity, + child: ElevatedButton( + child: Text( + AppLocalizations.of(context)!.addToken, + style: Theme.of(context).textTheme.headlineSmall, + overflow: TextOverflow.fade, + softWrap: false, + ), + onPressed: () => addToken(Uri.parse(textController.text)), + ), + ), + ], + ); +} diff --git a/pubspec.yaml b/pubspec.yaml index a146fc51..c01dd0c9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,7 +14,7 @@ publish_to: none # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 4.4.2+404201 # TODO Set the right version number +version: 4.4.2+404202 # TODO Set the right version number # version: major.minor.build + 2x major|2x minor|3x build # version: version number + build number (optional)