mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-12-24 02:33:44 +03:00
Merge pull request #69 from Yubico/drag-n-drop
Draft for drag n dropping files
This commit is contained in:
commit
f6aeb0b9f0
@ -125,7 +125,7 @@ final menuActionsProvider = Provider.autoDispose<List<MenuAction>>((ref) {
|
||||
});
|
||||
|
||||
abstract class QrScanner {
|
||||
Future<String> scanQr();
|
||||
Future<String> scanQr([String? imageData]);
|
||||
}
|
||||
|
||||
final qrScannerProvider = Provider<QrScanner?>(
|
||||
|
@ -9,8 +9,8 @@ class RpcQrScanner implements QrScanner {
|
||||
RpcQrScanner(this._rpc);
|
||||
|
||||
@override
|
||||
Future<String> scanQr() async {
|
||||
final result = await _rpc.command('qr', []);
|
||||
Future<String> scanQr([String? imageData]) async {
|
||||
final result = await _rpc.command('qr', [], params: {'image': imageData});
|
||||
return result['result'];
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'dart:math';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@ -7,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../app/state.dart';
|
||||
import '../../app/models.dart';
|
||||
import '../../app/views/responsive_dialog.dart';
|
||||
import '../../widgets/file_drop_target.dart';
|
||||
import '../models.dart';
|
||||
import '../state.dart';
|
||||
import 'utils.dart';
|
||||
@ -109,196 +111,215 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
|
||||
return ResponsiveDialog(
|
||||
title: const Text('Add account'),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Account details',
|
||||
style: Theme.of(context).textTheme.headline6,
|
||||
),
|
||||
TextField(
|
||||
controller: _issuerController,
|
||||
autofocus: true,
|
||||
enabled: issuerRemaining > 0,
|
||||
maxLength: max(issuerRemaining, 1),
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Issuer (optional)',
|
||||
helperText: '', // Prevents dialog resizing when enabled = false
|
||||
child: FileDropTarget(
|
||||
onFileDropped: (fileData) async {
|
||||
if (qrScanner != null) {
|
||||
final b64Image = base64Encode(fileData);
|
||||
final otpauth = await qrScanner.scanQr(b64Image);
|
||||
final data = CredentialData.fromUri(Uri.parse(otpauth));
|
||||
setState(() {
|
||||
_issuerController.text = data.issuer ?? '';
|
||||
_accountController.text = data.name;
|
||||
_secretController.text = data.secret;
|
||||
_oathType = data.oathType;
|
||||
_hashAlgorithm = data.hashAlgorithm;
|
||||
_periodController.text = '${data.period}';
|
||||
_digits = data.digits;
|
||||
_qrState = _QrScanState.success;
|
||||
});
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Account details',
|
||||
style: Theme.of(context).textTheme.headline6,
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
// Update maxlengths
|
||||
});
|
||||
},
|
||||
),
|
||||
TextField(
|
||||
controller: _accountController,
|
||||
maxLength: nameRemaining,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Account name',
|
||||
helperText: '', // Prevents dialog resizing when enabled = false
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
// Update maxlengths
|
||||
});
|
||||
},
|
||||
),
|
||||
TextField(
|
||||
controller: _secretController,
|
||||
inputFormatters: <TextInputFormatter>[
|
||||
FilteringTextInputFormatter.allow(_secretFormatterPattern)
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: 'Secret key',
|
||||
errorText: _validateSecretLength && !secretLengthValid
|
||||
? 'Invalid length'
|
||||
: null),
|
||||
enabled: _qrState != _QrScanState.success,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_validateSecretLength = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (qrScanner != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 24.0),
|
||||
child: Row(
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
_scanQrCode(qrScanner);
|
||||
},
|
||||
icon: const Icon(Icons.qr_code),
|
||||
label: const Text('Scan QR code'),
|
||||
),
|
||||
const SizedBox(width: 8.0),
|
||||
..._buildQrStatus(),
|
||||
],
|
||||
TextField(
|
||||
controller: _issuerController,
|
||||
autofocus: true,
|
||||
enabled: issuerRemaining > 0,
|
||||
maxLength: max(issuerRemaining, 1),
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Issuer (optional)',
|
||||
helperText: '', // Prevents dialog resizing when enabled = false
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
// Update maxlengths
|
||||
});
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
Text(
|
||||
'Options',
|
||||
style: Theme.of(context).textTheme.headline6,
|
||||
),
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 4.0,
|
||||
runSpacing: 8.0,
|
||||
children: [
|
||||
FilterChip(
|
||||
label: const Text('Require touch'),
|
||||
selected: _touch,
|
||||
onSelected: (value) {
|
||||
setState(() {
|
||||
_touch = value;
|
||||
});
|
||||
},
|
||||
TextField(
|
||||
controller: _accountController,
|
||||
maxLength: nameRemaining,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
labelText: 'Account name',
|
||||
helperText: '', // Prevents dialog resizing when enabled = false
|
||||
),
|
||||
Chip(
|
||||
label: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<OathType>(
|
||||
value: _oathType,
|
||||
isDense: true,
|
||||
underline: null,
|
||||
items: OathType.values
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text(e.name.toUpperCase()),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: _qrState != _QrScanState.success
|
||||
? (type) {
|
||||
setState(() {
|
||||
_oathType = type ?? OathType.totp;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
// Update maxlengths
|
||||
});
|
||||
},
|
||||
),
|
||||
TextField(
|
||||
controller: _secretController,
|
||||
inputFormatters: <TextInputFormatter>[
|
||||
FilteringTextInputFormatter.allow(_secretFormatterPattern)
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: 'Secret key',
|
||||
errorText: _validateSecretLength && !secretLengthValid
|
||||
? 'Invalid length'
|
||||
: null),
|
||||
enabled: _qrState != _QrScanState.success,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_validateSecretLength = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (qrScanner != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 24.0),
|
||||
child: Row(
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
_scanQrCode(qrScanner);
|
||||
},
|
||||
icon: const Icon(Icons.qr_code),
|
||||
label: const Text('Scan QR code'),
|
||||
),
|
||||
const SizedBox(width: 8.0),
|
||||
..._buildQrStatus(),
|
||||
],
|
||||
),
|
||||
),
|
||||
Chip(
|
||||
label: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<HashAlgorithm>(
|
||||
value: _hashAlgorithm,
|
||||
isDense: true,
|
||||
underline: null,
|
||||
items: HashAlgorithm.values
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text(e.name.toUpperCase()),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: _qrState != _QrScanState.success
|
||||
? (type) {
|
||||
setState(() {
|
||||
_hashAlgorithm = type ?? HashAlgorithm.sha1;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
const Divider(),
|
||||
Text(
|
||||
'Options',
|
||||
style: Theme.of(context).textTheme.headline6,
|
||||
),
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 4.0,
|
||||
runSpacing: 8.0,
|
||||
children: [
|
||||
FilterChip(
|
||||
label: const Text('Require touch'),
|
||||
selected: _touch,
|
||||
onSelected: (value) {
|
||||
setState(() {
|
||||
_touch = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
if (_oathType == OathType.totp)
|
||||
Chip(
|
||||
label: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<int>(
|
||||
value:
|
||||
int.tryParse(_periodController.text) ?? defaultPeriod,
|
||||
child: DropdownButton<OathType>(
|
||||
value: _oathType,
|
||||
isDense: true,
|
||||
underline: null,
|
||||
items: [20, 30, 45, 60]
|
||||
items: OathType.values
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text('$e sec'),
|
||||
child: Text(e.name.toUpperCase()),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: _qrState != _QrScanState.success
|
||||
? (period) {
|
||||
? (type) {
|
||||
setState(() {
|
||||
_periodController.text =
|
||||
'${period ?? defaultPeriod}';
|
||||
_oathType = type ?? OathType.totp;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
Chip(
|
||||
label: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<int>(
|
||||
value: _digits,
|
||||
isDense: true,
|
||||
underline: null,
|
||||
items: [6, 7, 8]
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text('$e digits'),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: _qrState != _QrScanState.success
|
||||
? (digits) {
|
||||
setState(() {
|
||||
_digits = digits ?? defaultDigits;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
Chip(
|
||||
label: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<HashAlgorithm>(
|
||||
value: _hashAlgorithm,
|
||||
isDense: true,
|
||||
underline: null,
|
||||
items: HashAlgorithm.values
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text(e.name.toUpperCase()),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: _qrState != _QrScanState.success
|
||||
? (type) {
|
||||
setState(() {
|
||||
_hashAlgorithm = type ?? HashAlgorithm.sha1;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
.map((e) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: e,
|
||||
))
|
||||
.toList(),
|
||||
if (_oathType == OathType.totp)
|
||||
Chip(
|
||||
label: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<int>(
|
||||
value: int.tryParse(_periodController.text) ??
|
||||
defaultPeriod,
|
||||
isDense: true,
|
||||
underline: null,
|
||||
items: [20, 30, 45, 60]
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text('$e sec'),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: _qrState != _QrScanState.success
|
||||
? (period) {
|
||||
setState(() {
|
||||
_periodController.text =
|
||||
'${period ?? defaultPeriod}';
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
Chip(
|
||||
label: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<int>(
|
||||
value: _digits,
|
||||
isDense: true,
|
||||
underline: null,
|
||||
items: [6, 7, 8]
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text('$e digits'),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: _qrState != _QrScanState.success
|
||||
? (digits) {
|
||||
setState(() {
|
||||
_digits = digits ?? defaultDigits;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
.map((e) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: e,
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
|
59
lib/widgets/file_drop_target.dart
Executable file
59
lib/widgets/file_drop_target.dart
Executable file
@ -0,0 +1,59 @@
|
||||
import 'package:desktop_drop/desktop_drop.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FileDropTarget extends StatefulWidget {
|
||||
final Widget child;
|
||||
final Function(List<int> filedata) onFileDropped;
|
||||
final Widget? overlay;
|
||||
|
||||
const FileDropTarget({
|
||||
Key? key,
|
||||
required this.child,
|
||||
required this.onFileDropped,
|
||||
this.overlay,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _FileDropTargetState();
|
||||
}
|
||||
|
||||
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: (_) {
|
||||
setState(() {
|
||||
_hovering = true;
|
||||
});
|
||||
},
|
||||
onDragExited: (_) {
|
||||
setState(() {
|
||||
_hovering = false;
|
||||
});
|
||||
},
|
||||
onDragDone: (details) async {
|
||||
for (final file in details.files) {
|
||||
widget.onFileDropped(await file.readAsBytes());
|
||||
}
|
||||
},
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
widget.child,
|
||||
if (_hovering) widget.overlay ?? _buildDefaultOverlay(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
@ -42,6 +42,7 @@ dependencies:
|
||||
json_annotation: ^4.4.0
|
||||
freezed_annotation: ^1.0.0
|
||||
window_manager: ^0.2.0
|
||||
desktop_drop: ^0.3.3
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
@ -110,7 +110,7 @@ class RootNode(RpcNode):
|
||||
|
||||
@action(closes_child=False)
|
||||
def qr(self, params, event, signal):
|
||||
return dict(result=scan_qr())
|
||||
return dict(result=scan_qr(params.get("image")))
|
||||
|
||||
|
||||
def _id_from_fingerprint(fp):
|
||||
|
@ -1,14 +1,20 @@
|
||||
import mss
|
||||
import zxingcpp
|
||||
import base64
|
||||
import io
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def scan_qr():
|
||||
with mss.mss() as sct:
|
||||
monitor = sct.monitors[0] # 0 is the special "all monitors" value.
|
||||
sct_img = sct.grab(monitor) # mss format
|
||||
img = Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX")
|
||||
|
||||
def scan_qr(image_data = None):
|
||||
if (image_data):
|
||||
msg = base64.b64decode(image_data)
|
||||
buf = io.BytesIO(msg)
|
||||
img = Image.open(buf)
|
||||
else:
|
||||
with mss.mss() as sct:
|
||||
monitor = sct.monitors[0] # 0 is the special "all monitors" value.
|
||||
sct_img = sct.grab(monitor) # mss format
|
||||
img = Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX")
|
||||
result = zxingcpp.read_barcode(img)
|
||||
if result.valid:
|
||||
return result.text
|
||||
|
Loading…
Reference in New Issue
Block a user