diff --git a/lib/app/views/app_page.dart b/lib/app/views/app_page.dart index a580a817..5fa2c296 100755 --- a/lib/app/views/app_page.dart +++ b/lib/app/views/app_page.dart @@ -19,6 +19,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import '../../core/state.dart'; import '../../widgets/delayed_visibility.dart'; +import '../../widgets/file_drop_target.dart'; import '../message.dart'; import 'keys.dart'; import 'navigation.dart'; @@ -36,6 +37,8 @@ class AppPage extends StatelessWidget { final bool centered; final bool delayedContent; final Widget Function(BuildContext context)? actionButtonBuilder; + final Widget? fileDropOverlay; + final Function(List filedata)? onFileDropped; const AppPage({ super.key, this.title, @@ -44,9 +47,12 @@ class AppPage extends StatelessWidget { this.centered = false, this.keyActionsBuilder, this.actionButtonBuilder, + this.fileDropOverlay, + this.onFileDropped, this.delayedContent = false, this.keyActionsBadge = false, - }); + }) : assert(!(onFileDropped != null && fileDropOverlay == null), + 'Declaring onFileDropped requires declaring a fileDropOverlay'); @override Widget build(BuildContext context) => LayoutBuilder( @@ -186,7 +192,9 @@ class AppPage extends StatelessWidget { centered ? Center(child: _buildMainContent()) : _buildMainContent(); if (hasRail) { body = Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: onFileDropped != null + ? CrossAxisAlignment.stretch + : CrossAxisAlignment.start, children: [ SizedBox( width: 72, @@ -198,7 +206,15 @@ class AppPage extends StatelessWidget { ), ), ), - Expanded(child: body), + Expanded( + child: onFileDropped != null + ? FileDropTarget( + onFileDropped: onFileDropped!, + overlay: fileDropOverlay!, + child: body, + ) + : body, + ), ], ); } @@ -254,7 +270,20 @@ class AppPage extends StatelessWidget { ], ), drawer: hasDrawer ? _buildDrawer(context) : null, - body: body, + body: onFileDropped != null && !hasRail + ? Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: FileDropTarget( + onFileDropped: onFileDropped!, + overlay: fileDropOverlay!, + child: body, + ), + ) + ], + ) + : body, ); } } diff --git a/lib/app/views/message_page.dart b/lib/app/views/message_page.dart index a03e1759..b448ddee 100755 --- a/lib/app/views/message_page.dart +++ b/lib/app/views/message_page.dart @@ -27,6 +27,8 @@ class MessagePage extends StatelessWidget { final bool delayedContent; final Widget Function(BuildContext context)? keyActionsBuilder; final Widget Function(BuildContext context)? actionButtonBuilder; + final Widget? fileDropOverlay; + final Function(List filedata)? onFileDropped; final bool keyActionsBadge; const MessagePage({ @@ -38,6 +40,8 @@ class MessagePage extends StatelessWidget { this.actions = const [], this.keyActionsBuilder, this.actionButtonBuilder, + this.fileDropOverlay, + this.onFileDropped, this.delayedContent = false, this.keyActionsBadge = false, }); @@ -49,6 +53,8 @@ class MessagePage extends StatelessWidget { actions: actions, keyActionsBuilder: keyActionsBuilder, keyActionsBadge: keyActionsBadge, + fileDropOverlay: fileDropOverlay, + onFileDropped: onFileDropped, actionButtonBuilder: actionButtonBuilder, delayedContent: delayedContent, child: Padding( diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index c5974cb4..c05149c0 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -325,6 +325,7 @@ "s_add_account": "Konto hinzufügen", "s_add_accounts": null, "p_add_description": null, + "l_drop_qr_description": null, "s_add_manually": null, "s_account_added": "Konto hinzugefügt", "l_account_add_failed": "Fehler beim Hinzufügen des Kontos: {message}", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 558875a1..b59639c9 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -325,6 +325,7 @@ "s_add_account": "Add account", "s_add_accounts": "Add account(s)", "p_add_description": "To scan a QR code, make sure the full code is visible on screen and press the button below. You can also drag a saved image from a folder onto this dialog. If you have the account credential details in writing, use the manual entry instead.", + "l_drop_qr_description": "Drop QR code to add account(s)", "s_add_manually": "Add manually", "s_account_added": "Account added", "l_account_add_failed": "Failed adding account: {message}", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 75c70e4c..83aeef0d 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -325,6 +325,7 @@ "s_add_account": "Ajouter un compte", "s_add_accounts": "Ajouter un/des compte(s)", "p_add_description": "Pour scanner un code QR, assurez-vous que le code complet est visible à l'écran et appuyez sur le bouton ci-dessous. Vous pouvez également faire glisser une image enregistrée dans un dossier vers cette boîte de dialogue. Si vous disposez des informations d'identification du compte par écrit, utilisez plutôt la saisie manuelle.", + "l_drop_qr_description": null, "s_add_manually": "Ajouter manuellement", "s_account_added": "Compte ajouté", "l_account_add_failed": "Échec d'ajout d'un compte: {message}", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 7b2074f6..0c7cdc73 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -325,6 +325,7 @@ "s_add_account": "アカウントの追加", "s_add_accounts": "アカウントの追加", "p_add_description": "QR コードをスキャンするには、コード全体が画面に表示されていることを確認し、下のボタンを押してください。保存した画像をこのダイアログにドラッグすることもできます。アカウントクレデンシャル情報を書面で持っている場合は、代わりに手動で入力をしてください。", + "l_drop_qr_description": null, "s_add_manually": "手動で追加", "s_account_added": "アカウントが追加されました", "l_account_add_failed": "アカウントの追加に失敗しました:{message}", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 6477d32e..6ced0ab1 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -325,6 +325,7 @@ "s_add_account": "Dodaj konto", "s_add_accounts": "Dodaj konto(-a)", "p_add_description": "W celu zeskanowania kodu QR, upewnij się, że pełny kod jest widoczny na ekranie a następnie naciśnij poniższy przycisk. Jeśli posiadasz dane uwierzytelniające do konta w tekstowej formie, skorzystaj z opcji ręcznego wprowadzania danych.", + "l_drop_qr_description": null, "s_add_manually": "Dodaj ręcznie", "s_account_added": "Konto zostało dodane", "l_account_add_failed": "Nie udało się dodać konta: {message}", diff --git a/lib/oath/views/add_account_dialog.dart b/lib/oath/views/add_account_dialog.dart index 06d8d749..014ff182 100644 --- a/lib/oath/views/add_account_dialog.dart +++ b/lib/oath/views/add_account_dialog.dart @@ -23,6 +23,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app/message.dart'; import '../../app/models.dart'; import '../../app/state.dart'; +import '../../widgets/file_drop_overlay.dart'; import '../../widgets/file_drop_target.dart'; import '../../widgets/responsive_dialog.dart'; import '../keys.dart'; @@ -50,88 +51,93 @@ class _AddAccountDialogState extends ConsumerState { final withContext = ref.read(withContextProvider); final qrScanner = ref.watch(qrScannerProvider); - return ResponsiveDialog( + return FileDropTarget( + onFileDropped: (fileData) async { + Navigator.of(context).pop(); + if (qrScanner != null) { + final b64Image = base64Encode(fileData); + final qrData = await qrScanner.scanQr(b64Image); + await withContext( + (context) async { + if (qrData != null) { + await handleUri(context, credentials, qrData, widget.devicePath, + widget.state, l10n); + } else { + showMessage(context, l10n.l_qr_not_found); + } + }, + ); + } + }, + overlay: FileDropOverlay( + title: l10n.s_add_account, + subtitle: l10n.l_drop_qr_description, + ), + child: ResponsiveDialog( title: Text(l10n.s_add_account), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 18.0), - child: FileDropTarget( - onFileDropped: (fileData) async { - Navigator.of(context).pop(); - if (qrScanner != null) { - final b64Image = base64Encode(fileData); - final qrData = await qrScanner.scanQr(b64Image); - await withContext( - (context) async { - if (qrData != null) { - await handleUri(context, credentials, qrData, - widget.devicePath, widget.state, l10n); - } else { - showMessage(context, l10n.l_qr_not_found); - } - }, - ); - } - }, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(l10n.p_add_description), - const SizedBox(height: 4), - Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 4.0, - runSpacing: 8.0, - children: [ - ActionChip( - avatar: const Icon(Icons.qr_code_scanner_outlined), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.p_add_description), + const SizedBox(height: 4), + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 4.0, + runSpacing: 8.0, + children: [ + ActionChip( + avatar: const Icon(Icons.qr_code_scanner_outlined), + backgroundColor: + Theme.of(context).colorScheme.surfaceVariant, + label: Text(l10n.s_qr_scan), + onPressed: () async { + if (qrScanner != null) { + final qrData = await qrScanner.scanQr(); + await withContext( + (context) async { + if (qrData != null) { + Navigator.of(context).pop(); + await handleUri(context, credentials, qrData, + widget.devicePath, widget.state, l10n); + } else { + showMessage(context, l10n.l_qr_not_found); + } + }, + ); + } + }, + ), + ActionChip( + key: addAccountManuallyButton, + avatar: const Icon(Icons.edit_outlined), backgroundColor: Theme.of(context).colorScheme.surfaceVariant, - label: Text(l10n.s_qr_scan), + label: Text(l10n.s_add_manually), onPressed: () async { - if (qrScanner != null) { - final qrData = await qrScanner.scanQr(); - await withContext( - (context) async { - if (qrData != null) { - Navigator.of(context).pop(); - await handleUri(context, credentials, qrData, - widget.devicePath, widget.state, l10n); - } else { - showMessage(context, l10n.l_qr_not_found); - } - }, + Navigator.of(context).pop(); + await withContext((context) async { + await showBlurDialog( + context: context, + builder: (context) => OathAddAccountPage( + widget.devicePath, + widget.state, + credentials: credentials, + ), ); - } - }, - ), - ActionChip( - key: addAccountManuallyButton, - avatar: const Icon(Icons.edit_outlined), - backgroundColor: - Theme.of(context).colorScheme.surfaceVariant, - label: Text(l10n.s_add_manually), - onPressed: () async { - Navigator.of(context).pop(); - await withContext((context) async { - await showBlurDialog( - context: context, - builder: (context) => OathAddAccountPage( - widget.devicePath, - widget.state, - credentials: credentials, - ), - ); - }); - }), - ]) - ] - .map((e) => Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: e, - )) - .toList(), - ), + }); + }), + ]) + ] + .map((e) => Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: e, + )) + .toList(), ), - )); + ), + ), + ); } } diff --git a/lib/oath/views/add_account_page.dart b/lib/oath/views/add_account_page.dart index ec3ef9e3..7541b3ed 100755 --- a/lib/oath/views/add_account_page.dart +++ b/lib/oath/views/add_account_page.dart @@ -38,6 +38,7 @@ import '../../management/models.dart'; import '../../widgets/app_input_decoration.dart'; import '../../widgets/app_text_field.dart'; import '../../widgets/choice_filter_chip.dart'; +import '../../widgets/file_drop_overlay.dart'; import '../../widgets/file_drop_target.dart'; import '../../widgets/focus_utils.dart'; import '../../widgets/responsive_dialog.dart'; @@ -308,41 +309,47 @@ class _OathAddAccountPageState extends ConsumerState { } } - return ResponsiveDialog( - title: Text(l10n.s_add_account), - actions: [ - TextButton( - onPressed: isValid ? submit : null, - child: Text(l10n.s_save, key: keys.saveButton), - ), - ], - child: FileDropTarget( - onFileDropped: (fileData) async { - final qrScanner = ref.read(qrScannerProvider); - if (qrScanner != null) { - final b64Image = base64Encode(fileData); - final otpauth = await qrScanner.scanQr(b64Image); - if (otpauth == null) { - if (!mounted) return; - showMessage(context, l10n.l_qr_not_found); - } else { + return FileDropTarget( + onFileDropped: (fileData) async { + final qrScanner = ref.read(qrScannerProvider); + final withContext = ref.read(withContextProvider); + if (qrScanner != null) { + final b64Image = base64Encode(fileData); + final qrData = await qrScanner.scanQr(b64Image); + await withContext((context) async { + if (qrData != null) { + List creds; try { - final data = CredentialData.fromOtpauth(Uri.parse(otpauth)); - _loadCredentialData(data); - } catch (e) { - final String errorMessage; - // TODO: Make this cleaner than importing desktop specific RpcError. - if (e is RpcError) { - errorMessage = e.message; - } else { - errorMessage = e.toString(); - } - if (!mounted) return; - showMessage(context, errorMessage); + creds = CredentialData.fromUri(Uri.parse(qrData)); + } catch (_) { + showMessage(context, l10n.l_invalid_qr); + return; } + if (creds.length == 1) { + _loadCredentialData(creds[0]); + } else { + Navigator.of(context).pop(); + await handleUri(context, widget.credentials, qrData, + widget.devicePath, widget.state, l10n); + } + } else { + showMessage(context, l10n.l_qr_not_found); } - } - }, + }); + } + }, + overlay: FileDropOverlay( + title: l10n.s_add_account, + subtitle: l10n.l_drop_qr_description, + ), + child: ResponsiveDialog( + title: Text(l10n.s_add_account), + actions: [ + TextButton( + onPressed: isValid ? submit : null, + child: Text(l10n.s_save, key: keys.saveButton), + ), + ], child: isLocked ? Padding( padding: const EdgeInsets.symmetric(vertical: 18), diff --git a/lib/oath/views/oath_screen.dart b/lib/oath/views/oath_screen.dart index b3213f7b..8256ece8 100755 --- a/lib/oath/views/oath_screen.dart +++ b/lib/oath/views/oath_screen.dart @@ -14,19 +14,24 @@ * limitations under the License. */ +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:flutter/services.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/shortcuts.dart'; +import '../../app/state.dart'; import '../../app/views/app_failure_page.dart'; import '../../app/views/app_page.dart'; import '../../app/views/message_page.dart'; import '../../core/state.dart'; import '../../widgets/app_input_decoration.dart'; import '../../widgets/app_text_form_field.dart'; +import '../../widgets/file_drop_overlay.dart'; import '../features.dart' as features; import '../keys.dart' as keys; import '../models.dart'; @@ -34,6 +39,7 @@ import '../state.dart'; import 'account_list.dart'; import 'key_actions.dart'; import 'unlock_form.dart'; +import 'utils.dart'; class OathScreen extends ConsumerWidget { final DevicePath devicePath; @@ -125,6 +131,27 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> { final numCreds = ref.watch(credentialListProvider(widget.devicePath) .select((value) => value?.length)); final hasActions = ref.watch(featureProvider)(features.actions); + + Future onFileDropped(List fileData) async { + final qrScanner = ref.read(qrScannerProvider); + if (qrScanner != null) { + final b64Image = base64Encode(fileData); + final qrData = await qrScanner.scanQr(b64Image); + final withContext = ref.read(withContextProvider); + await withContext( + (context) async { + if (qrData != null) { + final credentials = ref.read(credentialsProvider); + await handleUri(context, credentials, qrData, widget.devicePath, + widget.oathState, l10n); + } else { + showMessage(context, l10n.l_qr_not_found); + } + }, + ); + } + } + if (numCreds == 0) { return MessagePage( title: Text(l10n.s_authenticator), @@ -137,6 +164,11 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> { context, widget.devicePath, widget.oathState, ref, used: 0) : null, + onFileDropped: onFileDropped, + fileDropOverlay: FileDropOverlay( + title: l10n.s_add_account, + subtitle: l10n.l_drop_qr_description, + ), ); } return Actions( @@ -218,6 +250,11 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> { used: numCreds ?? 0, ) : null, + onFileDropped: onFileDropped, + fileDropOverlay: FileDropOverlay( + title: l10n.s_add_account, + subtitle: l10n.l_drop_qr_description, + ), centered: numCreds == null, delayedContent: numCreds == null, child: numCreds != null diff --git a/lib/widgets/file_drop_overlay.dart b/lib/widgets/file_drop_overlay.dart new file mode 100644 index 00000000..6e85cc6b --- /dev/null +++ b/lib/widgets/file_drop_overlay.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +class FileDropOverlay extends StatelessWidget { + final Widget? graphic; + final String? title; + final String? subtitle; + + const FileDropOverlay({super.key, this.graphic, this.title, this.subtitle}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .secondaryContainer + .withOpacity(0.95), + border: Border.all(color: Theme.of(context).colorScheme.primary), + borderRadius: const BorderRadius.all(Radius.circular(20.0))), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + graphic ?? + Icon( + Icons.upload_file, + size: 120, + color: Theme.of(context).colorScheme.primary, + ), + if (title != null) ...[ + const SizedBox(height: 16.0), + Text( + title!, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge, + ) + ], + if (subtitle != null) ...[ + const SizedBox(height: 12.0), + Text( + subtitle!, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleSmall, + ) + ] + ], + ), + ); + } +} diff --git a/lib/widgets/file_drop_target.dart b/lib/widgets/file_drop_target.dart index 70fdc5ec..a6e62bd4 100755 --- a/lib/widgets/file_drop_target.dart +++ b/lib/widgets/file_drop_target.dart @@ -22,13 +22,13 @@ import '../core/state.dart'; class FileDropTarget extends StatefulWidget { final Widget child; final Function(List filedata) onFileDropped; - final Widget? overlay; + final Widget overlay; const FileDropTarget({ super.key, required this.child, required this.onFileDropped, - this.overlay, + required this.overlay, }); @override @@ -38,41 +38,43 @@ class FileDropTarget extends StatefulWidget { class _FileDropTargetState extends State { bool _hovering = false; - Widget _buildDefaultOverlay() => Positioned.fill( - child: Container( - color: Colors.blue.withOpacity(0.4), - child: Icon( - Icons.upload_file, - size: 200, - color: Colors.black.withOpacity(0.6), - ), - ), - ); - @override - Widget build(BuildContext context) => DropTarget( - onDragEntered: (_) { + Widget build(BuildContext context) { + return DropTarget( + onDragEntered: (_) { + // Multiple FileDropTarget widgets can be in the tree at the same + // time. We only want to use the top-most. + if (ModalRoute.of(context)!.isCurrent) { setState(() { _hovering = true; }); - }, - onDragExited: (_) { - setState(() { - _hovering = false; - }); - }, - onDragDone: (details) async { + } + }, + onDragExited: (_) { + setState(() { + _hovering = false; + }); + }, + onDragDone: (details) async { + if (ModalRoute.of(context)!.isCurrent) { for (final file in details.files) { widget.onFileDropped(await file.readAsBytes()); } - }, - enable: !isAndroid, - child: Stack( - alignment: Alignment.center, - children: [ - widget.child, - if (_hovering) widget.overlay ?? _buildDefaultOverlay(), - ], - ), - ); + } + }, + enable: !isAndroid, + child: Stack( + children: [ + widget.child, + if (_hovering) + Positioned.fill( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: widget.overlay, + ), + ) + ], + ), + ); + } }