diff --git a/CHANGELOG.md b/CHANGELOG.md index 43bccd613..7a046f471 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- Feat: Open scanner on send button + ## [1.7.0] - 2023-12-07 - Fix: Reduce WebSocket reconnect timeout to 200ms. diff --git a/mobile/lib/common/routes.dart b/mobile/lib/common/routes.dart index f04577701..1c36269e8 100644 --- a/mobile/lib/common/routes.dart +++ b/mobile/lib/common/routes.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get_10101/common/global_keys.dart'; import 'package:get_10101/common/settings/channel_screen.dart'; import 'package:get_10101/common/status_screen.dart'; +import 'package:get_10101/features/wallet/domain/destination.dart'; import 'package:get_10101/features/welcome/loading_screen.dart'; import 'package:get_10101/common/scaffold_with_nav_bar.dart'; import 'package:get_10101/common/settings/app_info_screen.dart'; @@ -152,10 +153,7 @@ GoRouter createRoutes() { // Use root navigator so the screen overlays the application shell parentNavigatorKey: rootNavigatorKey, builder: (BuildContext context, GoRouterState state) { - if (state.extra != null) { - return SendScreen(encodedDestination: state.extra as String?); - } - return const SendScreen(); + return SendScreen(destination: state.extra as Destination); }, ), GoRoute( diff --git a/mobile/lib/features/wallet/scanner_screen.dart b/mobile/lib/features/wallet/scanner_screen.dart index a2308fc03..34a059cf0 100644 --- a/mobile/lib/features/wallet/scanner_screen.dart +++ b/mobile/lib/features/wallet/scanner_screen.dart @@ -2,10 +2,16 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:get_10101/common/color.dart'; +import 'package:get_10101/features/wallet/domain/destination.dart'; import 'package:get_10101/features/wallet/send/send_screen.dart'; +import 'package:get_10101/features/wallet/wallet_change_notifier.dart'; import 'package:get_10101/features/wallet/wallet_screen.dart'; import 'package:get_10101/logger/logger.dart'; import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; import 'package:qr_code_scanner/qr_code_scanner.dart'; class ScannerScreen extends StatefulWidget { @@ -22,6 +28,14 @@ class _ScannerScreenState extends State { QRViewController? controller; final GlobalKey qrKey = GlobalKey(debugLabel: 'QR'); + final _formKey = GlobalKey(); + String? _encodedDestination; + Destination? _destination; + + final textEditingController = TextEditingController(); + + bool cameraPermission = kDebugMode; + @override void reassemble() { super.reassemble(); @@ -37,38 +51,178 @@ class _ScannerScreenState extends State { @override Widget build(BuildContext context) { + final walletService = context.read().service; + return Scaffold( - appBar: AppBar(title: const Text("Scan")), - body: QRView( - key: qrKey, - onQRViewCreated: _onQRViewCreated, - overlay: QrScannerOverlayShape( - borderColor: Colors.red, - borderRadius: 10, - borderLength: 30, - borderWidth: 10, - cutOutSize: 300.0), - formatsAllowed: const [BarcodeFormat.qrcode], - onPermissionSet: (ctrl, p) => _onPermissionSet(context, ctrl, p), - )); - } + body: SafeArea( + child: Container( + margin: const EdgeInsets.only(top: 10), + child: Column(children: [ + Container( + margin: const EdgeInsets.only(left: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + GestureDetector( + child: Container( + alignment: AlignmentDirectional.topStart, + decoration: BoxDecoration( + color: Colors.transparent, borderRadius: BorderRadius.circular(10)), + width: 70, + child: const Icon( + Icons.arrow_back_ios_new_rounded, + size: 22, + )), + onTap: () { + GoRouter.of(context).pop(); + }, + ), + ], + ), + ), + const SizedBox(height: 20), + Expanded( + child: !cameraPermission + ? const Padding( + padding: EdgeInsets.only(left: 25, right: 25), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(FontAwesomeIcons.ban, size: 55), + SizedBox(height: 20), + Text( + "To scan a QR code you must give 10101 permission to access the camera.", + textAlign: TextAlign.center) + ]), + ) + : QRView( + key: qrKey, + onQRViewCreated: (controller) { + setState(() { + this.controller = controller; + }); + controller.scannedDataStream.listen((scanData) { + if (scanData.code != null) { + setState(() { + _encodedDestination = scanData.code; + textEditingController.text = _encodedDestination ?? ""; + }); + walletService + .decodeDestination(scanData.code ?? "") + .then((destination) { + _destination = destination; + if (_formKey.currentState!.validate()) { + GoRouter.of(context).go(SendScreen.route, extra: destination); + } + }); + } + }); + }, + overlay: QrScannerOverlayShape( + borderColor: Colors.red, + borderRadius: 10, + borderLength: 30, + borderWidth: 10, + cutOutSize: 300.0), + formatsAllowed: const [BarcodeFormat.qrcode], + onPermissionSet: (ctrl, p) => _onPermissionSet(context, ctrl, p), + )), + Container( + margin: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text("Destination"), + const SizedBox(height: 5), + Form( + key: _formKey, + child: TextFormField( + validator: (value) { + if (_destination == null) { + return "Invalid destination"; + } + + return null; + }, + onChanged: (value) => _encodedDestination = value, + controller: textEditingController, + decoration: InputDecoration( + filled: true, + fillColor: Colors.grey.shade200, + suffixIcon: GestureDetector( + onTap: () async { + final data = await Clipboard.getData("text/plain"); - void _onQRViewCreated(QRViewController controller) { - setState(() { - this.controller = controller; - }); - controller.scannedDataStream.listen((scanData) { - GoRouter.of(context).go(SendScreen.route, extra: scanData.code); - }); + if (data?.text != null) { + setState(() { + _encodedDestination = data!.text!; + textEditingController.text = _encodedDestination!; + }); + } + }, + child: const Icon(Icons.paste_rounded, color: tenTenOnePurple, size: 18), + ), + enabledBorder: + OutlineInputBorder(borderSide: BorderSide(color: Colors.grey.shade200)), + border: + OutlineInputBorder(borderSide: BorderSide(color: Colors.grey.shade200)), + ), + ), + ), + const SizedBox(height: 20), + SizedBox( + width: MediaQuery.of(context).size.width * 0.9, + child: ElevatedButton( + onPressed: () { + walletService + .decodeDestination(_encodedDestination ?? "") + .then((destination) { + _destination = destination; + + if (_formKey.currentState!.validate()) { + GoRouter.of(context).go(SendScreen.route, extra: destination); + } + }); + }, + style: ButtonStyle( + padding: MaterialStateProperty.all(const EdgeInsets.all(15)), + backgroundColor: MaterialStateProperty.resolveWith((states) { + if (states.contains(MaterialState.disabled)) { + return tenTenOnePurple.shade100; + } else { + return tenTenOnePurple; + } + }), + shape: MaterialStateProperty.resolveWith((states) { + if (states.contains(MaterialState.disabled)) { + return RoundedRectangleBorder( + borderRadius: BorderRadius.circular(30.0), + side: BorderSide(color: tenTenOnePurple.shade100), + ); + } else { + return RoundedRectangleBorder( + borderRadius: BorderRadius.circular(30.0), + side: const BorderSide(color: tenTenOnePurple), + ); + } + })), + child: const Text( + "Next", + style: TextStyle(fontSize: 18, color: Colors.white), + )), + ), + ], + ), + ), + ]), + ), + )); } - void _onPermissionSet(BuildContext context, QRViewController ctrl, bool p) { - logger.d('${DateTime.now().toIso8601String()}_onPermissionSet $p'); - if (!p) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('no Permission')), - ); - } + void _onPermissionSet(BuildContext context, QRViewController ctrl, bool permission) { + logger.d('${DateTime.now().toIso8601String()}_onPermissionSet $permission'); + setState(() => cameraPermission = permission); } @override diff --git a/mobile/lib/features/wallet/send/enter_destination_modal.dart b/mobile/lib/features/wallet/send/enter_destination_modal.dart deleted file mode 100644 index 23d7e3ba3..000000000 --- a/mobile/lib/features/wallet/send/enter_destination_modal.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get_10101/features/wallet/scanner_screen.dart'; -import 'package:go_router/go_router.dart'; - -void showEnterDestinationModal(BuildContext context, Function onSetDestination) { - showModalBottomSheet( - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: Radius.circular(20), - ), - ), - clipBehavior: Clip.antiAlias, - isScrollControlled: true, - useRootNavigator: true, - context: context, - builder: (BuildContext context) { - return SafeArea( - child: Padding( - padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), - // the GestureDetector ensures that we can close the keyboard by tapping into the modal - child: GestureDetector( - onTap: () { - FocusScopeNode currentFocus = FocusScope.of(context); - - if (!currentFocus.hasPrimaryFocus) { - currentFocus.unfocus(); - } - }, - child: SingleChildScrollView( - child: SizedBox( - // TODO: Find a way to make height dynamic depending on the children size - // This is needed because otherwise the keyboard does not push the sheet up correctly - height: 200, - child: EnterDestinationModal(onSetDestination: onSetDestination), - ), - ), - ), - )); - }); -} - -class EnterDestinationModal extends StatefulWidget { - final Function onSetDestination; - - const EnterDestinationModal({super.key, required this.onSetDestination}); - - @override - State createState() => _EnterDestinationModalState(); -} - -class _EnterDestinationModalState extends State { - String? destination; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(left: 20.0, top: 30.0, right: 20.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - TextFormField( - decoration: InputDecoration( - labelText: "Destination", - hintText: "e.g. an invoice, BIP21 URI or on-chain address", - suffixIcon: IconButton( - icon: const Icon(Icons.qr_code), - onPressed: () { - GoRouter.of(context).go(ScannerScreen.route); - })), - onChanged: (value) { - destination = value; - }, - ), - const SizedBox(height: 20), - ElevatedButton( - onPressed: () { - widget.onSetDestination(destination); - GoRouter.of(context).pop(); - }, - child: const Text("Set Destination", style: TextStyle(fontSize: 16))) - ], - ), - ); - } -} diff --git a/mobile/lib/features/wallet/send/send_screen.dart b/mobile/lib/features/wallet/send/send_screen.dart index ec36faf38..c4c49ffd2 100644 --- a/mobile/lib/features/wallet/send/send_screen.dart +++ b/mobile/lib/features/wallet/send/send_screen.dart @@ -12,19 +12,17 @@ import 'package:get_10101/features/wallet/application/wallet_service.dart'; import 'package:get_10101/features/wallet/domain/destination.dart'; import 'package:get_10101/features/wallet/domain/wallet_type.dart'; import 'package:get_10101/features/wallet/send/confirm_payment_modal.dart'; -import 'package:get_10101/features/wallet/send/enter_destination_modal.dart'; import 'package:get_10101/features/wallet/wallet_change_notifier.dart'; import 'package:get_10101/features/wallet/wallet_screen.dart'; -import 'package:get_10101/logger/logger.dart'; import 'package:provider/provider.dart'; class SendScreen extends StatefulWidget { static const route = "${WalletScreen.route}/$subRouteName"; static const subRouteName = "send"; - final String? encodedDestination; + final Destination destination; - const SendScreen({super.key, this.encodedDestination}); + const SendScreen({super.key, required this.destination}); @override State createState() => _SendScreenState(); @@ -33,11 +31,9 @@ class SendScreen extends StatefulWidget { class _SendScreenState extends State { final _formKey = GlobalKey(); bool _valid = false; - bool _invalidDestination = false; ChannelInfo? channelInfo; - Destination? _destination; Amount _amount = Amount.zero(); final TextEditingController _controller = TextEditingController(); @@ -58,27 +54,14 @@ class _SendScreenState extends State { Future init(ChannelInfoService channelInfoService, WalletService walletService) async { channelInfo = await channelInfoService.getChannelInfo(); - if (widget.encodedDestination != null) { - final destination = await walletService.decodeDestination(widget.encodedDestination!); - setState(() { - if (destination != null) { - _destination = destination; - _amount = destination.amount; - _controller.text = _amount.formatted(); - - _invalidDestination = false; - _valid = _formKey.currentState?.validate() ?? false; - } else { - _invalidDestination = false; - } - }); - } + setState(() { + _amount = widget.destination.amount; + _controller.text = _amount.formatted(); + }); } @override Widget build(BuildContext context) { - final WalletService walletService = context.watch().service; - final balance = getBalance(); return Scaffold( @@ -91,28 +74,23 @@ class _SendScreenState extends State { child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Text("Destination", style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 2), + InputDecorator( + decoration: InputDecoration( + enabledBorder: + const OutlineInputBorder(borderSide: BorderSide(color: Colors.black12)), + labelStyle: const TextStyle(color: Colors.black87), + filled: true, + fillColor: Colors.grey[50], + ), + child: Text(truncateWithEllipsis(26, widget.destination.raw), + style: const TextStyle(fontSize: 15)), + ), + const SizedBox(height: 15), + const Text("From", style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 2), OutlinedButton( - onPressed: () => showEnterDestinationModal(context, (encodedDestination) { - walletService.decodeDestination(encodedDestination).then((destination) { - if (destination == null) { - logger.w("Invalid destination!"); - setState(() => _invalidDestination = true); - return; - } - - setState(() { - _destination = destination; - _amount = destination.amount; - _controller.text = _amount.formatted(); - - _invalidDestination = false; - _valid = _formKey.currentState?.validate() ?? false; - }); - }); - }), + onPressed: null, style: OutlinedButton.styleFrom( - side: - BorderSide(color: _invalidDestination ? Colors.red[900]! : Colors.black87), minimumSize: const Size(20, 60), backgroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), @@ -120,65 +98,32 @@ class _SendScreenState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - _destination?.raw != null - ? truncateWithEllipsis(26, _destination!.raw) - : "Set destination", - style: const TextStyle(color: Colors.black87, fontSize: 16)), - const Icon(Icons.edit, size: 20) + Row(mainAxisAlignment: MainAxisAlignment.start, children: [ + Icon( + widget.destination.getWalletType() == WalletType.lightning + ? Icons.bolt + : Icons.currency_bitcoin, + size: 30), + const SizedBox(width: 5), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.destination.getWalletType() == WalletType.lightning + ? "Lightning" + : "On-chain", + style: const TextStyle(color: Colors.black87, fontSize: 16), + ), + Text(formatSats(balance[widget.destination.getWalletType()]!.$1)) + ], + ), + ]), + const Icon(Icons.arrow_drop_down_sharp, size: 30) ], )), - Visibility( - visible: _invalidDestination, - child: Padding( - padding: const EdgeInsets.only(left: 10, top: 10, bottom: 10), - child: Text("Invalid destination", - style: TextStyle(color: Colors.red[900], fontSize: 12)))), - const SizedBox(height: 15), - const Text("From", style: TextStyle(fontWeight: FontWeight.bold)), - const SizedBox(height: 2), - OutlinedButton( - onPressed: null, - style: OutlinedButton.styleFrom( - minimumSize: const Size(20, 60), - backgroundColor: _destination != null ? Colors.white : Colors.white24, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - child: Visibility( - visible: _destination != null, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _destination != null - ? Row(mainAxisAlignment: MainAxisAlignment.start, children: [ - Icon( - _destination!.getWalletType() == WalletType.lightning - ? Icons.bolt - : Icons.currency_bitcoin, - size: 30), - const SizedBox(width: 5), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _destination!.getWalletType() == WalletType.lightning - ? "Lightning" - : "On-chain", - style: const TextStyle(color: Colors.black87, fontSize: 16), - ), - Text(formatSats(balance[_destination!.getWalletType()]!.$1)) - ], - ), - ]) - : Container(), - const Icon(Icons.arrow_drop_down_sharp, size: 30) - ], - ), - )), const SizedBox(height: 20), Visibility( - visible: - _destination != null && _destination!.getWalletType() == WalletType.onChain, + visible: widget.destination.getWalletType() == WalletType.onChain, replacement: const Text( "Amount in sats", style: TextStyle(fontWeight: FontWeight.bold), @@ -193,7 +138,7 @@ class _SendScreenState extends State { controller: _controller, label: "", value: _amount, - enabled: _destination != null && _destination!.amount.sats == 0, + enabled: widget.destination.amount.sats == 0, onChanged: (value) { setState(() { _amount = Amount.parseAmount(value); @@ -207,7 +152,8 @@ class _SendScreenState extends State { final amount = Amount.parseAmount(value); - if (amount.sats <= 0 && _destination!.getWalletType() == WalletType.lightning) { + if (amount.sats <= 0 && + widget.destination.getWalletType() == WalletType.lightning) { return "Amount cannot be 0"; } @@ -215,16 +161,12 @@ class _SendScreenState extends State { return "Amount cannot be negative"; } - if (_destination == null) { - return "Missing destination"; - } - - final bal = balance[_destination!.getWalletType()]!.$1; + final bal = balance[widget.destination.getWalletType()]!.$1; if (amount.sats > bal.sats) { return "Not enough funds."; } - final usebal = balance[_destination!.getWalletType()]!.$2; + final usebal = balance[widget.destination.getWalletType()]!.$2; if (amount.sats > usebal.sats) { return "Not enough funds. ${formatSats(bal.sub(usebal))} have to remain."; @@ -244,8 +186,7 @@ class _SendScreenState extends State { filled: true, fillColor: Colors.grey[50], ), - child: Text(_destination != null ? _destination!.description : "", - style: const TextStyle(fontSize: 15)), + child: Text(widget.destination.description, style: const TextStyle(fontSize: 15)), ), Expanded( child: Column( @@ -255,7 +196,7 @@ class _SendScreenState extends State { ElevatedButton( onPressed: !_valid ? null - : () => showConfirmPaymentModal(context, _destination!, _amount), + : () => showConfirmPaymentModal(context, widget.destination, _amount), child: const Text("Next")), ], ), diff --git a/mobile/lib/features/wallet/wallet_screen.dart b/mobile/lib/features/wallet/wallet_screen.dart index 051b780b1..2da4f150f 100644 --- a/mobile/lib/features/wallet/wallet_screen.dart +++ b/mobile/lib/features/wallet/wallet_screen.dart @@ -6,7 +6,7 @@ import 'package:get_10101/features/stable/stable_screen.dart'; import 'package:get_10101/features/wallet/balance.dart'; import 'package:get_10101/features/wallet/receive_screen.dart'; import 'package:get_10101/features/wallet/onboarding/onboarding_screen.dart'; -import 'package:get_10101/features/wallet/send/send_screen.dart'; +import 'package:get_10101/features/wallet/scanner_screen.dart'; import 'package:get_10101/features/wallet/wallet_change_notifier.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; @@ -96,7 +96,7 @@ class WalletScreen extends StatelessWidget { Expanded( child: ElevatedButton( style: balanceButtonStyle, - onPressed: () => GoRouter.of(context).go(SendScreen.route), + onPressed: () => GoRouter.of(context).go(ScannerScreen.route), child: const Row( mainAxisAlignment: MainAxisAlignment.center, children: [