From bc191cd8fe7dea99b44f53f26d426d3bda1ca8fe Mon Sep 17 00:00:00 2001 From: Frank Merkel <138444693+frankmer@users.noreply.github.com> Date: Thu, 7 Nov 2024 12:32:53 +0100 Subject: [PATCH 1/3] Add a LinkInputView widget for manual token entry via URL --- lib/l10n/app_cs.arb | 5 +- lib/l10n/app_de.arb | 5 +- lib/l10n/app_en.arb | 5 +- lib/l10n/app_es.arb | 5 +- lib/l10n/app_fr.arb | 5 +- lib/l10n/app_localizations.dart | 18 ++ lib/l10n/app_localizations_cs.dart | 9 + lib/l10n/app_localizations_de.dart | 9 + lib/l10n/app_localizations_en.dart | 9 + lib/l10n/app_localizations_es.dart | 9 + lib/l10n/app_localizations_fr.dart | 9 + lib/l10n/app_localizations_nl.dart | 9 + lib/l10n/app_localizations_pl.dart | 9 + lib/l10n/app_nl.arb | 5 +- lib/l10n/app_pl.arb | 5 +- .../add_token_manually_view.dart | 233 ++++++++++-------- .../PageViewDotIndicator.dart | 83 +++++++ .../link_input_field.dart | 99 ++++++++ pubspec.yaml | 2 +- 19 files changed, 422 insertions(+), 111 deletions(-) create mode 100644 lib/views/add_token_manually_view/add_token_manually_view_widgets/PageViewDotIndicator.dart create mode 100644 lib/views/add_token_manually_view/add_token_manually_view_widgets/link_input_field.dart diff --git a/lib/l10n/app_cs.arb b/lib/l10n/app_cs.arb index 8056f4a29..7e4d695dd 100644 --- a/lib/l10n/app_cs.arb +++ b/lib/l10n/app_cs.arb @@ -674,5 +674,8 @@ "qrInFileNotFound2": "Můžete mi ukázat, kde se QR kód nachází.", "qrInFileNotFound3": "Předpokládám, že kód najdu, pokud se nachází uprostřed označené oblasti.", "markQrCode": "Označte QR kód", - "malformedData": "Data nejsou ve správném formátu" + "malformedData": "Data nejsou ve správném formátu", + "linkMustOtpAuth" : "Odkaz musí začínat otpauth://", + "clipboardEmpty": "Schránka je prázdná", + "invalidUrl": "Neplatná adresa URL" } \ No newline at end of file diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 1ac83a565..3689151a1 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -653,5 +653,8 @@ "qrInFileNotFound2": "Sie können mir zeigen, wo sich der QR-Code befindet.", "qrInFileNotFound3": "Ich erwarte, dass ich den Code finde, wenn er sich in der Mitte des markierten Bereichs befindet.", "markQrCode": "QR-Code markieren", - "malformedData": "Fehlerhafte Daten" + "malformedData": "Fehlerhafte Daten", + "linkMustOtpAuth" : "Der Link muss mit otpauth:// beginnen", + "clipboardEmpty": "Zwischenablage ist leer", + "invalidUrl": "Ungültige URL" } \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1367214f6..6633a51b0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -696,5 +696,8 @@ "qrInFileNotFound2": "You can show me where the QR code is located.", "qrInFileNotFound3": "I expect i will find the code if it is in the middle of the marked area.", "markQrCode": "Mark QR Code", - "malformedData": "Malformed data" + "malformedData": "Malformed data", + "linkMustOtpAuth" : "The link must start with otpauth://", + "clipboardEmpty": "Clipboard is empty", + "invalidUrl": "Invalid URL" } \ No newline at end of file diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index d8e7b164f..a5cf4b5f8 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -670,5 +670,8 @@ "qrInFileNotFound2": "Puedes mostrarme dónde está el código QR.", "qrInFileNotFound3": "Espero encontrar el código si está en el centro del área marcada.", "markQrCode": "Marcar código QR", - "malformedData": "Datos mal formados" + "malformedData": "Datos mal formados", + "linkMustOtpAuth": "El enlace debe empezar por otpauth://", + "clipboardEmpty": "El portapapeles está vacío", + "invalidUrl": "URL no válida" } \ No newline at end of file diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 0ed1e81e1..35ebf6136 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -675,5 +675,8 @@ "qrInFileNotFound2": "Vous pouvez me montrer où se trouve le code QR.", "qrInFileNotFound3": "Je pense que je trouverai le code s'il se trouve au milieu de la zone marquée.", "markQrCode": "Marquer le code QR", - "malformedData": "Les données ne sont pas valides" + "malformedData": "Les données ne sont pas valides", + "linkMustOtpAuth": "Le lien doit commencer par otpauth://.", + "clipboardEmpty": "Le presse-papiers est vide", + "invalidUrl": "URL invalide" } \ No newline at end of file diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 5b1cb2a32..927fd7834 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1844,6 +1844,24 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Malformed data'** String get malformedData; + + /// No description provided for @linkMustOtpAuth. + /// + /// In en, this message translates to: + /// **'The link must start with otpauth://'** + String get linkMustOtpAuth; + + /// No description provided for @clipboardEmpty. + /// + /// In en, this message translates to: + /// **'Clipboard is empty'** + String get clipboardEmpty; + + /// No description provided for @invalidUrl. + /// + /// In en, this message translates to: + /// **'Invalid URL'** + String get invalidUrl; } class _AppLocalizationsDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/app_localizations_cs.dart b/lib/l10n/app_localizations_cs.dart index fb47d007a..53afaf04d 100644 --- a/lib/l10n/app_localizations_cs.dart +++ b/lib/l10n/app_localizations_cs.dart @@ -975,4 +975,13 @@ class AppLocalizationsCs extends AppLocalizations { @override String get malformedData => 'Data nejsou ve správném formátu'; + + @override + String get linkMustOtpAuth => 'Odkaz musí začínat otpauth://'; + + @override + String get clipboardEmpty => 'Schránka je prázdná'; + + @override + String get invalidUrl => 'Neplatná adresa URL'; } diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index d12f3ff50..13985f6b5 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -975,4 +975,13 @@ class AppLocalizationsDe extends AppLocalizations { @override String get malformedData => 'Fehlerhafte Daten'; + + @override + String get linkMustOtpAuth => 'Der Link muss mit otpauth:// beginnen'; + + @override + String get clipboardEmpty => 'Zwischenablage ist leer'; + + @override + String get invalidUrl => 'Ungültige URL'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 946f1b4d0..865dd25da 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -975,4 +975,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get malformedData => 'Malformed data'; + + @override + String get linkMustOtpAuth => 'The link must start with otpauth://'; + + @override + String get clipboardEmpty => 'Clipboard is empty'; + + @override + String get invalidUrl => 'Invalid URL'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 5af9608a5..404f2efa9 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -975,4 +975,13 @@ class AppLocalizationsEs extends AppLocalizations { @override String get malformedData => 'Datos mal formados'; + + @override + String get linkMustOtpAuth => 'El enlace debe empezar por otpauth://'; + + @override + String get clipboardEmpty => 'El portapapeles está vacío'; + + @override + String get invalidUrl => 'URL no válida'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 491e45bb6..5446d27ba 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -975,4 +975,13 @@ class AppLocalizationsFr extends AppLocalizations { @override String get malformedData => 'Les données ne sont pas valides'; + + @override + String get linkMustOtpAuth => 'Le lien doit commencer par otpauth://.'; + + @override + String get clipboardEmpty => 'Le presse-papiers est vide'; + + @override + String get invalidUrl => 'URL invalide'; } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 36fa6e563..89df34181 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -975,4 +975,13 @@ class AppLocalizationsNl extends AppLocalizations { @override String get malformedData => 'De QR code bevat onjuiste gegevens.'; + + @override + String get linkMustOtpAuth => 'De link moet beginnen met otpauth://'; + + @override + String get clipboardEmpty => 'Klembord is leeg'; + + @override + String get invalidUrl => 'Ongeldige URL'; } diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index aa9753be1..7c202a11f 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -975,4 +975,13 @@ class AppLocalizationsPl extends AppLocalizations { @override String get malformedData => 'Nieprawidłowe dane'; + + @override + String get linkMustOtpAuth => 'Link musi zaczynać się od otpauth://'; + + @override + String get clipboardEmpty => 'Schowek jest pusty'; + + @override + String get invalidUrl => 'Nieprawidłowy adres URL'; } diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index f16e1b01e..fc20a050d 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -671,5 +671,8 @@ "qrInFileNotFound2": "U kunt mij laten zien waar de QR code is.", "qrInFileNotFound3": "Ik verwacht dat ik de code zal vinden als het in het midden van het gemarkeerde gebied is.", "markQrCode": "Markeer QR Code", - "malformedData": "De QR code bevat onjuiste gegevens." + "malformedData": "De QR code bevat onjuiste gegevens.", + "linkMustOtpAuth": "De link moet beginnen met otpauth://", + "clipboardEmpty": "Klembord is leeg", + "invalidUrl": "Ongeldige URL" } \ No newline at end of file diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 3dd41525d..6df99f680 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -668,5 +668,8 @@ "qrInFileNotFound2": "Możesz pokazać mi, gdzie znajduje się kod QR.", "qrInFileNotFound3": "Oczekuję, że znajdę kod, jeśli znajduje się w środku zaznaczonego obszaru.", "markQrCode": "Zaznacz kod QR", - "malformedData": "Nieprawidłowe dane" + "malformedData": "Nieprawidłowe dane", + "linkMustOtpAuth": "Link musi zaczynać się od otpauth://", + "clipboardEmpty": "Schowek jest pusty", + "invalidUrl": "Nieprawidłowy adres URL" } \ No newline at end of file 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 d46c6049c..2e68c04de 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,7 +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/labeled_dropdown_button.dart'; +import 'add_token_manually_view_widgets/link_input_field.dart'; class AddTokenManuallyView extends ConsumerStatefulWidget { static const routeName = '/add_token_manually'; @@ -39,6 +41,8 @@ class _AddTokenManuallyViewState extends ConsumerState { final TextEditingController _labelController = TextEditingController(); final TextEditingController _secretController = TextEditingController(); + final PageController pageController = PageController(); + // fields needed to manage the widget final _labelInputKey = GlobalKey(); final _secretInputKey = GlobalKey(); @@ -69,112 +73,135 @@ class _AddTokenManuallyViewState extends ConsumerState { maxLines: 2, // Title can be shown on small screens too. ), ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(20), - child: Form( - child: Column( - 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*/, - ), - ), - SizedBox( - width: double.infinity, - child: ElevatedButton( - child: Text( - AppLocalizations.of(context)!.addToken, - style: Theme.of(context).textTheme.headlineSmall, - overflow: TextOverflow.fade, - softWrap: false, + body: Column( + children: [ + PageViewDotIndicator( + controller: pageController, + icons: [ + Icon(Icons.edit), + Icon(Icons.link), + ], + ), + Expanded( + child: PageView( + controller: pageController, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16.0, 0.0, 16.0, 16.0), + child: Form( + child: Column( + 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( + child: Text( + AppLocalizations.of(context)!.addToken, + style: Theme.of(context).textTheme.headlineSmall, + overflow: TextOverflow.fade, + softWrap: false, + ), + onPressed: () { + final token = _buildTokenIfValid(context: context); + if (token != null) { + ref.read(tokenProvider.notifier).addOrReplaceToken(token); + Navigator.pop(context); + } + }, + ), + ), + ], + ), ), - onPressed: () { - final token = _buildTokenIfValid(context: context); - if (token != null) { - ref.read(tokenProvider.notifier).addOrReplaceToken(token); - Navigator.pop(context); - } - }, ), - ), - ], + Padding( + padding: const EdgeInsets.fromLTRB(16.0, 0.0, 16.0, 16.0), + child: LinkInputView(), + ) + ], + ), ), - ), + ], ), ); } diff --git a/lib/views/add_token_manually_view/add_token_manually_view_widgets/PageViewDotIndicator.dart b/lib/views/add_token_manually_view/add_token_manually_view_widgets/PageViewDotIndicator.dart new file mode 100644 index 000000000..b14225864 --- /dev/null +++ b/lib/views/add_token_manually_view/add_token_manually_view_widgets/PageViewDotIndicator.dart @@ -0,0 +1,83 @@ +/* + * 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 PageViewDotIndicator extends ConsumerStatefulWidget { + final PageController controller; + final List icons; + const PageViewDotIndicator({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 SizedBox( + width: constraints.maxWidth, + height: 50, + child: 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(5), + ), + 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_field.dart b/lib/views/add_token_manually_view/add_token_manually_view_widgets/link_input_field.dart new file mode 100644 index 000000000..64a4f7ee7 --- /dev/null +++ b/lib/views/add_token_manually_view/add_token_manually_view_widgets/link_input_field.dart @@ -0,0 +1,99 @@ +/* + * 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: 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 835813f81..a146fc514 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.1+404101 # TODO Set the right version number +version: 4.4.2+404201 # TODO Set the right version number # version: major.minor.build + 2x major|2x minor|3x build # version: version number + build number (optional) From 67350ef075716efd7b0b0b0c1e216f5052c8195e Mon Sep 17 00:00:00 2001 From: Frank Merkel <138444693+frankmer@users.noreply.github.com> Date: Fri, 8 Nov 2024 09:26:03 +0100 Subject: [PATCH 2/3] Landscape pixel overflow fix --- .../add_token_manually_view.dart | 170 +++++++++--------- .../PageViewDotIndicator.dart | 46 +++-- ..._input_field.dart => link_input_view.dart} | 26 +-- pubspec.yaml | 2 +- 4 files changed, 126 insertions(+), 118 deletions(-) rename lib/views/add_token_manually_view/add_token_manually_view_widgets/{link_input_field.dart => link_input_view.dart} (80%) 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 2e68c04de..0f0f3b6a7 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 @@ -17,7 +17,7 @@ import '../../utils/logger.dart'; import '../../utils/riverpod_providers.dart'; import 'add_token_manually_view_widgets/PageViewDotIndicator.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'; @@ -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/PageViewDotIndicator.dart b/lib/views/add_token_manually_view/add_token_manually_view_widgets/PageViewDotIndicator.dart index b14225864..a41496ed1 100644 --- a/lib/views/add_token_manually_view/add_token_manually_view_widgets/PageViewDotIndicator.dart +++ b/lib/views/add_token_manually_view/add_token_manually_view_widgets/PageViewDotIndicator.dart @@ -49,33 +49,29 @@ class _PageViewDotIndicatorState extends ConsumerState { final widthPerIcon = constraints.maxWidth / (widget.icons.length + 1); final space = widthPerIcon * 0.1; final double iconWidth = widthPerIcon - space; - return SizedBox( - width: constraints.maxWidth, - height: 50, - child: 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(5), - ), - child: widget.icons[i], + 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(5), ), + child: widget.icons[i], ), - if (i < widget.icons.length - 1) SizedBox(width: space), - ] - ], - ), + ), + 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_field.dart b/lib/views/add_token_manually_view/add_token_manually_view_widgets/link_input_view.dart similarity index 80% rename from lib/views/add_token_manually_view/add_token_manually_view_widgets/link_input_field.dart rename to lib/views/add_token_manually_view/add_token_manually_view_widgets/link_input_view.dart index 64a4f7ee7..4f5eb3a1e 100644 --- a/lib/views/add_token_manually_view/add_token_manually_view_widgets/link_input_field.dart +++ b/lib/views/add_token_manually_view/add_token_manually_view_widgets/link_input_view.dart @@ -52,19 +52,21 @@ class _LinkInputViewState extends ConsumerState { Row( children: [ Expanded( - child: TextFormField( - controller: textController, - decoration: InputDecoration( - labelText: AppLocalizations.of(context)!.tokenLink, + 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, ), - 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), diff --git a/pubspec.yaml b/pubspec.yaml index a146fc514..c01dd0c95 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) From d7ed7190f13d7e8240e87513a23db71404b87843 Mon Sep 17 00:00:00 2001 From: Frank Merkel <138444693+frankmer@users.noreply.github.com> Date: Fri, 8 Nov 2024 09:34:27 +0100 Subject: [PATCH 3/3] ends of PageViewIndicator is now round --- .../add_token_manually_view.dart | 4 ++-- ...ageViewDotIndicator.dart => PageViewIndicator.dart} | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) rename lib/views/add_token_manually_view/add_token_manually_view_widgets/{PageViewDotIndicator.dart => PageViewIndicator.dart} (86%) 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 0f0f3b6a7..9b425ae1c 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,7 +15,7 @@ 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_view.dart'; @@ -75,7 +75,7 @@ class _AddTokenManuallyViewState extends ConsumerState { ), body: Column( children: [ - PageViewDotIndicator( + PageViewIndicator( controller: pageController, icons: [ Icon(Icons.edit), diff --git a/lib/views/add_token_manually_view/add_token_manually_view_widgets/PageViewDotIndicator.dart b/lib/views/add_token_manually_view/add_token_manually_view_widgets/PageViewIndicator.dart similarity index 86% rename from lib/views/add_token_manually_view/add_token_manually_view_widgets/PageViewDotIndicator.dart rename to lib/views/add_token_manually_view/add_token_manually_view_widgets/PageViewIndicator.dart index a41496ed1..418ccc577 100644 --- a/lib/views/add_token_manually_view/add_token_manually_view_widgets/PageViewDotIndicator.dart +++ b/lib/views/add_token_manually_view/add_token_manually_view_widgets/PageViewIndicator.dart @@ -20,16 +20,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -class PageViewDotIndicator extends ConsumerStatefulWidget { +class PageViewIndicator extends ConsumerStatefulWidget { final PageController controller; final List icons; - const PageViewDotIndicator({super.key, required this.controller, required this.icons}); + const PageViewIndicator({super.key, required this.controller, required this.icons}); @override - ConsumerState createState() => _PageViewDotIndicatorState(); + ConsumerState createState() => _PageViewDotIndicatorState(); } -class _PageViewDotIndicatorState extends ConsumerState { +class _PageViewDotIndicatorState extends ConsumerState { int _currentPage = 0; @override @@ -64,7 +64,7 @@ class _PageViewDotIndicatorState extends ConsumerState { width: _currentPage == i ? iconWidth * 2 : iconWidth, decoration: BoxDecoration( color: _currentPage == i ? Theme.of(context).primaryColor : Theme.of(context).disabledColor, - borderRadius: BorderRadius.circular(5), + borderRadius: BorderRadius.circular(99), ), child: widget.icons[i], ),