This commit is contained in:
Elias Bonnici 2024-01-09 13:09:48 +01:00
commit 6742519454
No known key found for this signature in database
GPG Key ID: 5EAC28EA3F980CCF
12 changed files with 284 additions and 143 deletions

View File

@ -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<int> 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,
);
}
}

View File

@ -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<int> 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(

View File

@ -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}",

View File

@ -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}",

View File

@ -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}",

View File

@ -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}",

View File

@ -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}",

View File

@ -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<AddAccountDialog> {
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(),
),
));
),
),
);
}
}

View File

@ -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<OathAddAccountPage> {
}
}
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<CredentialData> 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),

View File

@ -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<void> onFileDropped(List<int> 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

View File

@ -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,
)
]
],
),
);
}
}

View File

@ -22,13 +22,13 @@ import '../core/state.dart';
class FileDropTarget extends StatefulWidget {
final Widget child;
final Function(List<int> 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<FileDropTarget> {
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,
),
)
],
),
);
}
}