From c96752ee4953e830d106897cb721910816141fe3 Mon Sep 17 00:00:00 2001 From: Cathal Tummon Date: Wed, 7 Aug 2019 10:49:48 +0100 Subject: [PATCH] iOS NFC NDEF support --- ios/Runner/Info.plist | 2 + ios/Runner/Runner.entitlements | 4 + lib/localization.dart | 5 + lib/ui/send/send_sheet.dart | 418 +++++++++++++++++++-------------- lib/ui/widgets/buttons.dart | 101 ++++++++ lib/util/deviceutil.dart | 46 ++++ lib/util/hapticutil.dart | 35 +-- pubspec.yaml | 3 +- 8 files changed, 401 insertions(+), 213 deletions(-) create mode 100644 lib/util/deviceutil.dart diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index de640031..1c0c1cf5 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -65,6 +65,8 @@ UIStatusBarStyle UIStatusBarStyleLightContent + NFCReaderUsageDescription + Allow contactless Nano payments UISupportedInterfaceOrientations UIInterfaceOrientationPortrait diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements index 903def2a..7d708760 100644 --- a/ios/Runner/Runner.entitlements +++ b/ios/Runner/Runner.entitlements @@ -4,5 +4,9 @@ aps-environment development + com.apple.developer.nfc.readersession.formats + + NDEF + diff --git a/lib/localization.dart b/lib/localization.dart index b78c3d88..d78b0655 100644 --- a/lib/localization.dart +++ b/lib/localization.dart @@ -122,6 +122,11 @@ class AppLocalization { desc: 'intro_new_wallet_seed_copied', name: 'seedCopied'); } + String get contactless { + return Intl.message('Contactless', + desc: 'send_contactless', name: 'contactless'); + } + String get scanQrCode { return Intl.message('Scan QR Code', desc: 'send_scan_qr', name: 'scanQrCode'); diff --git a/lib/ui/send/send_sheet.dart b/lib/ui/send/send_sheet.dart index 163acddb..f148808e 100755 --- a/lib/ui/send/send_sheet.dart +++ b/lib/ui/send/send_sheet.dart @@ -1,4 +1,5 @@ import 'dart:math'; +import 'dart:io' show Platform; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -6,6 +7,7 @@ import 'package:flutter/services.dart'; import 'package:decimal/decimal.dart'; import 'package:barcode_scan/barcode_scan.dart'; import 'package:intl/intl.dart'; +import 'package:uni_links/uni_links.dart'; import 'package:natrium_wallet_flutter/appstate_container.dart'; import 'package:natrium_wallet_flutter/dimens.dart'; @@ -24,6 +26,7 @@ import 'package:natrium_wallet_flutter/ui/util/formatters.dart'; import 'package:natrium_wallet_flutter/ui/util/ui_util.dart'; import 'package:natrium_wallet_flutter/util/numberutil.dart'; import 'package:natrium_wallet_flutter/util/caseconverter.dart'; +import 'package:natrium_wallet_flutter/util/deviceutil.dart'; class AppSendSheet { FocusNode _sendAddressFocusNode; @@ -75,6 +78,237 @@ class AppSendSheet { ); } + _scanQR(BuildContext context, StateSetter setState){ + try { + UIUtil.cancelLockEvent(); + BarcodeScanner.scan(StateContainer.of(context) + .curTheme + .qrScanTheme) + .then((value) { + Address address = Address(value); + if (!address.isValid()) { + UIUtil.showSnackbar( + AppLocalization.of(context) + .qrInvalidAddress, + context); + } else { + sl.get() + .getContactWithAddress( + address.address) + .then((contact) { + if (contact == null) { + setState(() { + _isContact = false; + _addressValidationText = ""; + _sendAddressStyle = AppStyles + .textStyleAddressText90( + context); + _pasteButtonVisible = false; + _showContactButton = false; + }); + _sendAddressController.text = + address.address; + _sendAddressFocusNode.unfocus(); + setState(() { + _addressValidAndUnfocused = true; + }); + } else { + // Is a contact + setState(() { + _isContact = true; + _addressValidationText = ""; + _sendAddressStyle = AppStyles + .textStyleAddressPrimary( + context); + _pasteButtonVisible = false; + _showContactButton = false; + }); + _sendAddressController.text = + contact.name; + } + // Fill amount + if (address.amount != null) { + if (_localCurrencyMode) { + toggleLocalCurrency( + context, setState); + _sendAmountController.text = + NumberUtil.getRawAsUsableString( + address.amount); + } else { + setState(() { + _rawAmount = address.amount; + // Indicate that this is a special amount if some digits are not displayed + if (NumberUtil + .getRawAsUsableString( + _rawAmount) + .replaceAll(",", "") == + NumberUtil + .getRawAsUsableDecimal( + _rawAmount) + .toString()) { + _sendAmountController + .text = NumberUtil + .getRawAsUsableString( + _rawAmount) + .replaceAll(",", ""); + } else { + _sendAmountController + .text = NumberUtil.truncateDecimal( + NumberUtil + .getRawAsUsableDecimal( + address + .amount), + digits: 6) + .toStringAsFixed(6) + + "~"; + } + }); + } + } + }); + _sendAddressFocusNode.unfocus(); + } + }); + } catch (e) { + if (e.code == + BarcodeScanner.CameraAccessDenied) { + // TODO - Permission Denied to use camera + } else { + // UNKNOWN ERROR + } + } + } + + Widget _sendButtonRow(BuildContext context, StateSetter setState) { + return Row( + children: [ + // Send Button + AppButton.buildAppButton( + context, + AppButtonType.PRIMARY, + AppLocalization.of(context).send, + Dimens.BUTTON_TOP_DIMENS, onPressed: () { + bool validRequest = + _validateRequest(context, setState); + if (_sendAddressController.text + .startsWith("@") && + validRequest) { + // Need to make sure its a valid contact + sl.get() + .getContactWithName( + _sendAddressController.text) + .then((contact) { + if (contact == null) { + setState(() { + _addressValidationText = + AppLocalization.of(context) + .contactInvalid; + }); + } else { + AppSendConfirmSheet( + _localCurrencyMode + ? NumberUtil.getAmountAsRaw( + _convertLocalCurrencyToCrypto( + context)) + : _rawAmount == null + ? NumberUtil.getAmountAsRaw( + _sendAmountController + .text) + : _rawAmount, + contact.address, + contactName: contact.name, + maxSend: _isMaxSend(context), + localCurrencyAmount: + _localCurrencyMode + ? _sendAmountController + .text + : null) + .mainBottomSheet(context); + } + }); + } else if (validRequest) { + AppSendConfirmSheet( + _localCurrencyMode + ? NumberUtil.getAmountAsRaw( + _convertLocalCurrencyToCrypto( + context)) + : _rawAmount == null + ? NumberUtil.getAmountAsRaw( + _sendAmountController + .text) + : _rawAmount, + _sendAddressController.text, + maxSend: _isMaxSend(context), + localCurrencyAmount: + _localCurrencyMode + ? _sendAmountController.text + : null) + .mainBottomSheet(context); + } + }), + ], + ); + } + + Widget _scanQRCodeButtonRow(BuildContext context, StateSetter setState) { + return Row( + children: [ + // Scan QR Code Button + AppButton.buildAppButton( + context, + AppButtonType.PRIMARY_OUTLINE, + AppLocalization.of(context).scanQrCode, + Dimens.BUTTON_BOTTOM_DIMENS, onPressed: () { + _scanQR(context, setState); + }), + ], + ); + } + + Widget _sendOptionsWithoutNFC(BuildContext context, StateSetter setState) { + return Column(children:[ + _sendButtonRow(context, setState), + _scanQRCodeButtonRow(context, setState), + ] + ); + } + + Widget _sendOptionsWithNFC(BuildContext context, StateSetter setState) { + return Column(children:[ + _sendButtonRow(context, setState), + AppButton.buildAppButtonSplit( + context, + AppButtonType.PRIMARY_OUTLINE, + AppLocalization.of(context).scanQrCode, + AppLocalization.of(context).contactless, + Dimens.BUTTON_BOTTOM_DIMENS, + onLeftPressed: () { + _scanQR(context, setState); + }, + onRightPressed: () { + try { + UIUtil.cancelLockEvent(); + startNFCSession(""); + } catch (e) { + stopNFCSession(); + } + } + ) + ], + ); + } + + _withOrWithoutNFCSendOptions(BuildContext context, StateSetter setState){ + return FutureBuilder( + future: DeviceUtil.supportsNFCReader(), + builder: (BuildContext context, AsyncSnapshot nfcReaderAvailable) { + if(nfcReaderAvailable.hasData && nfcReaderAvailable.data){ + return _sendOptionsWithNFC(context, setState); + } + return _sendOptionsWithoutNFC(context, setState); + }); + } + mainBottomSheet(BuildContext context) { _sendAmountFocusNode = new FocusNode(); _sendAddressFocusNode = new FocusNode(); @@ -492,189 +726,9 @@ class AppSendSheet { ), ), - //A column with "Scan QR Code" and "Send" buttons + //A column with "Scan QR Code" and "Send" buttons and "Scan NFC Tag" on iOS Container( - child: Column( - children: [ - Row( - children: [ - // Send Button - AppButton.buildAppButton( - context, - AppButtonType.PRIMARY, - AppLocalization.of(context).send, - Dimens.BUTTON_TOP_DIMENS, onPressed: () { - bool validRequest = - _validateRequest(context, setState); - if (_sendAddressController.text - .startsWith("@") && - validRequest) { - // Need to make sure its a valid contact - sl.get() - .getContactWithName( - _sendAddressController.text) - .then((contact) { - if (contact == null) { - setState(() { - _addressValidationText = - AppLocalization.of(context) - .contactInvalid; - }); - } else { - AppSendConfirmSheet( - _localCurrencyMode - ? NumberUtil.getAmountAsRaw( - _convertLocalCurrencyToCrypto( - context)) - : _rawAmount == null - ? NumberUtil.getAmountAsRaw( - _sendAmountController - .text) - : _rawAmount, - contact.address, - contactName: contact.name, - maxSend: _isMaxSend(context), - localCurrencyAmount: - _localCurrencyMode - ? _sendAmountController - .text - : null) - .mainBottomSheet(context); - } - }); - } else if (validRequest) { - AppSendConfirmSheet( - _localCurrencyMode - ? NumberUtil.getAmountAsRaw( - _convertLocalCurrencyToCrypto( - context)) - : _rawAmount == null - ? NumberUtil.getAmountAsRaw( - _sendAmountController - .text) - : _rawAmount, - _sendAddressController.text, - maxSend: _isMaxSend(context), - localCurrencyAmount: - _localCurrencyMode - ? _sendAmountController.text - : null) - .mainBottomSheet(context); - } - }), - ], - ), - Row( - children: [ - // Scan QR Code Button - AppButton.buildAppButton( - context, - AppButtonType.PRIMARY_OUTLINE, - AppLocalization.of(context).scanQrCode, - Dimens.BUTTON_BOTTOM_DIMENS, onPressed: () { - try { - UIUtil.cancelLockEvent(); - BarcodeScanner.scan(StateContainer.of(context) - .curTheme - .qrScanTheme) - .then((value) { - Address address = Address(value); - if (!address.isValid()) { - UIUtil.showSnackbar( - AppLocalization.of(context) - .qrInvalidAddress, - context); - } else { - sl.get() - .getContactWithAddress( - address.address) - .then((contact) { - if (contact == null) { - setState(() { - _isContact = false; - _addressValidationText = ""; - _sendAddressStyle = AppStyles - .textStyleAddressText90( - context); - _pasteButtonVisible = false; - _showContactButton = false; - }); - _sendAddressController.text = - address.address; - _sendAddressFocusNode.unfocus(); - setState(() { - _addressValidAndUnfocused = true; - }); - } else { - // Is a contact - setState(() { - _isContact = true; - _addressValidationText = ""; - _sendAddressStyle = AppStyles - .textStyleAddressPrimary( - context); - _pasteButtonVisible = false; - _showContactButton = false; - }); - _sendAddressController.text = - contact.name; - } - // Fill amount - if (address.amount != null) { - if (_localCurrencyMode) { - toggleLocalCurrency( - context, setState); - _sendAmountController.text = - NumberUtil.getRawAsUsableString( - address.amount); - } else { - setState(() { - _rawAmount = address.amount; - // Indicate that this is a special amount if some digits are not displayed - if (NumberUtil - .getRawAsUsableString( - _rawAmount) - .replaceAll(",", "") == - NumberUtil - .getRawAsUsableDecimal( - _rawAmount) - .toString()) { - _sendAmountController - .text = NumberUtil - .getRawAsUsableString( - _rawAmount) - .replaceAll(",", ""); - } else { - _sendAmountController - .text = NumberUtil.truncateDecimal( - NumberUtil - .getRawAsUsableDecimal( - address - .amount), - digits: 6) - .toStringAsFixed(6) + - "~"; - } - }); - } - } - }); - _sendAddressFocusNode.unfocus(); - } - }); - } catch (e) { - if (e.code == - BarcodeScanner.CameraAccessDenied) { - // TODO - Permission Denied to use camera - } else { - // UNKNOWN ERROR - } - } - }), - ], - ), - ], - ), + child: _withOrWithoutNFCSendOptions(context, setState), ), ], )); diff --git a/lib/ui/widgets/buttons.dart b/lib/ui/widgets/buttons.dart index 417c1c85..0d14cd70 100644 --- a/lib/ui/widgets/buttons.dart +++ b/lib/ui/widgets/buttons.dart @@ -208,4 +208,105 @@ class AppButton { throw new UIException("Invalid Button Type $type"); } } // + + static Widget buildAppButtonSplit(BuildContext context, AppButtonType type, + String leftText, String rightText, List dimens, + {Function onLeftPressed, Function onRightPressed, bool disabled = false}) { + switch (type) { + case AppButtonType.PRIMARY_OUTLINE: + return Row( children:[ + Expanded( + child: Container( + decoration: BoxDecoration( + color: StateContainer.of(context).curTheme.backgroundDark, + borderRadius: BorderRadius.only(topLeft: Radius.circular(100), bottomLeft: Radius.circular(100)), + boxShadow: [StateContainer.of(context).curTheme.boxShadowButton], + ), + height: 55, + margin: EdgeInsetsDirectional.fromSTEB(dimens[0], dimens[1], 0, dimens[3]), + child: OutlineButton( + color: StateContainer.of(context).curTheme.backgroundDark, + textColor: disabled + ? StateContainer.of(context).curTheme.primary60 + : StateContainer.of(context).curTheme.primary, + borderSide: BorderSide( + color: disabled + ? StateContainer.of(context).curTheme.primary60 + : StateContainer.of(context).curTheme.primary, + width: 2.0), + highlightedBorderColor: disabled + ? StateContainer.of(context).curTheme.primary60 + : StateContainer.of(context).curTheme.primary, + splashColor: StateContainer.of(context).curTheme.text30, + highlightColor: StateContainer.of(context).curTheme.text15, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only(topLeft: Radius.circular(100), bottomLeft: Radius.circular(100))), + child: AutoSizeText( + leftText, + textAlign: TextAlign.center, + style: disabled + ? AppStyles.textStyleButtonPrimaryOutlineDisabled(context) + : AppStyles.textStyleButtonPrimaryOutline(context), + maxLines: 1, + stepGranularity: 0.5, + ), + onPressed: () { + if (onLeftPressed != null) { + onLeftPressed(); + } + return; + }, + ), + ) + ), + Expanded( + child: Container( + decoration: BoxDecoration( + color: StateContainer.of(context).curTheme.backgroundDark, + borderRadius: BorderRadius.only(topRight: Radius.circular(100), bottomRight: Radius.circular(100)), + boxShadow: [StateContainer.of(context).curTheme.boxShadowButton], + ), + height: 55, + margin: EdgeInsetsDirectional.fromSTEB(0, dimens[1], dimens[2], dimens[3]), + child: OutlineButton( + color: StateContainer.of(context).curTheme.backgroundDark, + textColor: disabled + ? StateContainer.of(context).curTheme.primary60 + : StateContainer.of(context).curTheme.primary, + borderSide: BorderSide( + color: disabled + ? StateContainer.of(context).curTheme.primary60 + : StateContainer.of(context).curTheme.primary, + width: 2.0), + highlightedBorderColor: disabled + ? StateContainer.of(context).curTheme.primary60 + : StateContainer.of(context).curTheme.primary, + splashColor: StateContainer.of(context).curTheme.text30, + highlightColor: StateContainer.of(context).curTheme.text15, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only(topRight: Radius.circular(100), bottomRight: Radius.circular(100))), + child: AutoSizeText( + rightText, + textAlign: TextAlign.center, + style: disabled + ? AppStyles.textStyleButtonPrimaryOutlineDisabled(context) + : AppStyles.textStyleButtonPrimaryOutline(context), + maxLines: 1, + stepGranularity: 0.5, + ), + onPressed: () { + if (onRightPressed != null) { + onRightPressed(); + } + return; + }, + ), + ) + ) + ] + ); + default: + throw new UIException("Invalid Button Type $type"); + } + } // } diff --git a/lib/util/deviceutil.dart b/lib/util/deviceutil.dart new file mode 100644 index 00000000..714d794b --- /dev/null +++ b/lib/util/deviceutil.dart @@ -0,0 +1,46 @@ +import 'dart:io'; +import 'package:device_info/device_info.dart'; + +/// Utilities for device specific info +class DeviceUtil { + /// Return true if this device is an iPhone7 or greater + static Future isIPhone7OrGreater() async { + if (!Platform.isIOS) { + return false; + } + IosDeviceInfo deviceInfo = await DeviceInfoPlugin().iosInfo; + String deviceIdentifier = deviceInfo.utsname.machine; + switch (deviceIdentifier) { + case 'iPhone5,1': // iPhone 5 + case 'iPhone5,2': // iPhone 5 + case 'iPhone5,3': // iPhone 5C + case 'iPhone5,4': // iPhone 5C + case 'iPhone6,1': // iPhone 5S + case 'iPhone6,2': // iPhone 5S + case 'iPhone7,2': // iPhone 6 + case 'iPhone7,1': // iPhone 6 plus + case 'iPhone8,1': // iPhone 6s + case 'iPhone8,2': // iPhone 6s plus + return false; + default: + return true; + } + } + + static Future isIOS11OrGreater() async { + if (!Platform.isIOS) { + return false; + } + IosDeviceInfo deviceInfo = await DeviceInfoPlugin().iosInfo; + String version = deviceInfo.systemVersion; + List l = version.split('.'); + if(l.length > 0){ + return int.parse(l.elementAt(0)) >= 11; + } + return false; + } + + static Future supportsNFCReader() async{ + return await isIPhone7OrGreater() && await isIOS11OrGreater(); + } +} diff --git a/lib/util/hapticutil.dart b/lib/util/hapticutil.dart index 0a08862d..08065646 100644 --- a/lib/util/hapticutil.dart +++ b/lib/util/hapticutil.dart @@ -2,39 +2,16 @@ import 'dart:io'; import 'package:flutter/services.dart'; import 'package:vibrate/vibrate.dart'; -import 'package:device_info/device_info.dart'; + +import 'package:natrium_wallet_flutter/util/deviceutil.dart'; /// Utilities for haptic feedback class HapticUtil { - /// Return true if this device supports taptic engine (iPhone 7+) - Future hasTapicEngine() async { - if (!Platform.isIOS) { - return false; - } - IosDeviceInfo deviceInfo = await DeviceInfoPlugin().iosInfo; - String deviceIdentifier = deviceInfo.utsname.machine; - switch (deviceIdentifier) { - case 'iPhone5,1': // iPhone 5 - case 'iPhone5,2': // iPhone 5 - case 'iPhone5,3': // iPhone 5C - case 'iPhone5,4': // iPhone 5C - case 'iPhone6,1': // iPhone 5S - case 'iPhone6,2': // iPhone 5S - case 'iPhone7,2': // iPhone 6 - case 'iPhone7,1': // iPhone 6 plus - case 'iPhone8,1': // iPhone 6s - case 'iPhone8,2': // iPhone 6s plus - return false; - default: - return true; - } - } - /// Feedback for error Future error() async { if (Platform.isIOS) { - // If this is simulator or this device doesnt have tapic then we can't use this - if (await hasTapicEngine() && await Vibrate.canVibrate) { + // If this is simulator or this device doesn't have tapic (iPhone 7+) then we can't use this + if (await DeviceUtil.isIPhone7OrGreater() && await Vibrate.canVibrate) { Vibrate.feedback(FeedbackType.error); } else { HapticFeedback.vibrate(); @@ -47,8 +24,8 @@ class HapticUtil { /// Feedback for success Future success() async { if (Platform.isIOS) { - // If this is simulator or this device doesnt have tapic then we can't use this - if (await hasTapicEngine() && await Vibrate.canVibrate) { + // If this is simulator or this device doesn't have tapic (iPhone 7+) then we can't use this + if (await DeviceUtil.isIPhone7OrGreater() && await Vibrate.canVibrate) { Vibrate.feedback(FeedbackType.medium); } else { HapticFeedback.mediumImpact(); diff --git a/pubspec.yaml b/pubspec.yaml index 935da94b..0bf555c4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -91,11 +91,10 @@ dependencies: file_picker: ^1.3.8 # Deep link support - #uni_links: ^0.2.0 uni_links: git: url: https://github.com/CathalT/uni_links.git - ref: nfc_android + ref: nfc_support # HTTP client http: ^0.12.0+2