From 53901ef6a5eb20944953c6fd3490cf51f432fa56 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sun, 29 Dec 2024 16:02:29 -0600 Subject: [PATCH] feat: WIP UI updates, add a MultisigCoordinator view TODO: disable "Continue" button on setup view unless all inputs have values. Auto-fill first input in Coordinator view. Add walletId params as necessary in order to get relevant metadata --- .../multisig_coordinator.dart | 0 .../multisig_coordinator_view.dart | 373 ++++++++++++++++++ .../multisig_setup_view.dart | 131 ++---- lib/pages/wallet_view/wallet_view.dart | 2 +- lib/route_generator.dart | 45 ++- .../crypto_currency/coins/bitcoin.dart | 6 +- lib/wallets/wallet/impl/bip48_wallet.dart | 5 +- 7 files changed, 450 insertions(+), 112 deletions(-) rename lib/pages/wallet_view/{multisig_setup_view => multisig_coordinator_view}/multisig_coordinator.dart (100%) create mode 100644 lib/pages/wallet_view/multisig_coordinator_view/multisig_coordinator_view.dart rename lib/pages/wallet_view/{multisig_setup_view => multisig_coordinator_view}/multisig_setup_view.dart (81%) diff --git a/lib/pages/wallet_view/multisig_setup_view/multisig_coordinator.dart b/lib/pages/wallet_view/multisig_coordinator_view/multisig_coordinator.dart similarity index 100% rename from lib/pages/wallet_view/multisig_setup_view/multisig_coordinator.dart rename to lib/pages/wallet_view/multisig_coordinator_view/multisig_coordinator.dart diff --git a/lib/pages/wallet_view/multisig_coordinator_view/multisig_coordinator_view.dart b/lib/pages/wallet_view/multisig_coordinator_view/multisig_coordinator_view.dart new file mode 100644 index 000000000..41470a7fd --- /dev/null +++ b/lib/pages/wallet_view/multisig_coordinator_view/multisig_coordinator_view.dart @@ -0,0 +1,373 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../themes/stack_colors.dart'; +import '../../../../utilities/text_styles.dart'; +import '../../../../widgets/background.dart'; +import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../../widgets/desktop/primary_button.dart'; +import '../../../widgets/desktop/secondary_button.dart'; +import '../../../widgets/icon_widgets/copy_icon.dart'; +import '../../../widgets/icon_widgets/qrcode_icon.dart'; + +final multisigCoordinatorStateProvider = + StateNotifierProvider( + (ref) { + return MultisigCoordinatorState(); +}); + +class MultisigCoordinatorData { + const MultisigCoordinatorData({ + this.threshold = 2, + this.totalCosigners = 3, + this.coinType = 0, // Bitcoin mainnet. + this.accountIndex = 0, + this.scriptType = MultisigScriptType.nativeSegwit, + this.cosignerXpubs = const [], + }); + + final int threshold; + final int totalCosigners; + final int coinType; + final int accountIndex; + final MultisigScriptType scriptType; + final List cosignerXpubs; + + MultisigCoordinatorData copyWith({ + int? threshold, + int? totalCosigners, + int? coinType, + int? accountIndex, + MultisigScriptType? scriptType, + List? cosignerXpubs, + }) { + return MultisigCoordinatorData( + threshold: threshold ?? this.threshold, + totalCosigners: totalCosigners ?? this.totalCosigners, + coinType: coinType ?? this.coinType, + accountIndex: accountIndex ?? this.accountIndex, + scriptType: scriptType ?? this.scriptType, + cosignerXpubs: cosignerXpubs ?? this.cosignerXpubs, + ); + } + + Map toJson() => { + 'threshold': threshold, + 'totalCosigners': totalCosigners, + 'coinType': coinType, + 'accountIndex': accountIndex, + 'scriptType': scriptType.index, + 'cosignerXpubs': cosignerXpubs, + }; + + factory MultisigCoordinatorData.fromJson(Map json) { + return MultisigCoordinatorData( + threshold: json['threshold'] as int, + totalCosigners: json['totalCosigners'] as int, + coinType: json['coinType'] as int, + accountIndex: json['accountIndex'] as int, + scriptType: MultisigScriptType.values[json['scriptType'] as int], + cosignerXpubs: (json['cosignerXpubs'] as List).cast(), + ); + } +} + +enum MultisigScriptType { + legacy, // P2SH. + segwit, // P2SH-P2WSH. + nativeSegwit, // P2WSH. +} + +class MultisigCoordinatorState extends StateNotifier { + MultisigCoordinatorState() : super(const MultisigCoordinatorData()); + + void updateThreshold(int threshold) { + state = state.copyWith(threshold: threshold); + } + + void updateTotalCosigners(int total) { + state = state.copyWith(totalCosigners: total); + } + + void updateScriptType(MultisigScriptType type) { + state = state.copyWith(scriptType: type); + } + + void addCosignerXpub(String xpub) { + if (state.cosignerXpubs.length < state.totalCosigners) { + state = state.copyWith( + cosignerXpubs: [...state.cosignerXpubs, xpub], + ); + } + } +} + +class MultisigCoordinatorView extends ConsumerStatefulWidget { + const MultisigCoordinatorView({ + super.key, + required this.totalCosigners, + required this.threshold, + }); + + final int totalCosigners; + final int threshold; + + static const String routeName = "/multisigCoordinator"; + + @override + ConsumerState createState() => + _MultisigSetupViewState(); +} + +class _MultisigSetupViewState extends ConsumerState { + final List xpubControllers = []; + // bool _isNfcAvailable = false; + // String _nfcStatus = 'Checking NFC availability...'; + + @override + void initState() { + super.initState(); + + // Initialize controllers. + for (int i = 0; i < widget.totalCosigners; i++) { + xpubControllers.add(TextEditingController()); + } + + // _checkNfcAvailability(); + } + + @override + void dispose() { + for (final controller in xpubControllers) { + controller.dispose(); + } + super.dispose(); + } + + // Future _checkNfcAvailability() async { + // try { + // final availability = await NfcManager.instance.isAvailable(); + // setState(() { + // _isNfcAvailable = availability; + // _nfcStatus = _isNfcAvailable + // ? 'NFC is available' + // : 'NFC is not available on this device'; + // }); + // } catch (e) { + // setState(() { + // _nfcStatus = 'Error checking NFC: $e'; + // _isNfcAvailable = false; + // }); + // } + // } + // + // Future _startNfcSession() async { + // if (!_isNfcAvailable) return; + // + // setState(() => _nfcStatus = 'Ready to exchange information...'); + // + // try { + // await NfcManager.instance.startSession( + // onDiscovered: (tag) async { + // try { + // final ndef = Ndef.from(tag); + // + // if (ndef == null) { + // setState(() => _nfcStatus = 'Tag is not NDEF compatible'); + // return; + // } + // + // final setupData = ref.watch(multisigSetupStateProvider); + // + // if (ndef.isWritable) { + // final message = NdefMessage([ + // NdefRecord.createMime( + // 'application/x-multisig-setup', + // Uint8List.fromList( + // utf8.encode(jsonEncode(setupData.toJson()))), + // ), + // ]); + // + // try { + // await ndef.write(message); + // setState( + // () => _nfcStatus = 'Configuration shared successfully'); + // } catch (e) { + // setState( + // () => _nfcStatus = 'Failed to share configuration: $e'); + // } + // } + // + // await NfcManager.instance.stopSession(); + // } catch (e) { + // setState(() => _nfcStatus = 'Error during NFC exchange: $e'); + // await NfcManager.instance.stopSession(); + // } + // }, + // ); + // } catch (e) { + // setState(() => _nfcStatus = 'Error: $e'); + // await NfcManager.instance.stopSession(); + // } + // } + + @override + Widget build(BuildContext context) { + return Background( + child: SafeArea( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + automaticallyImplyLeading: false, + leading: AppBarBackButton( + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 75)); + } + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + ), + title: Text( + "Enter cosigner xpubs", + style: STextStyles.navBarTitle(context), + ), + titleSpacing: 0, + ), + body: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Enter the extended public key (xpub) for each cosigner. " + "These can be obtained from each participant's wallet.", + style: STextStyles.itemSubtitle(context), + ), + const SizedBox(height: 24), + + // Generate input fields for each cosigner + for (int i = 0; i < widget.totalCosigners; i++) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Cosigner ${i + 1} xpub", + style: + STextStyles.w500_14(context).copyWith( + color: Theme.of(context) + .extension()! + .textDark3, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextField( + controller: xpubControllers[i], + decoration: InputDecoration( + hintText: "Enter xpub", + hintStyle: + STextStyles.fieldLabel(context), + ), + onChanged: (value) { + if (value.isNotEmpty) { + ref + .read( + multisigCoordinatorStateProvider + .notifier) + .addCosignerXpub(value); + } + setState( + () {}); // Trigger rebuild to update button state. + }, + ), + ), + const SizedBox(width: 8), + SecondaryButton( + width: 44, + buttonHeight: ButtonHeight.xl, + icon: QrCodeIcon( + width: 20, + height: 20, + color: Theme.of(context) + .extension()! + .buttonTextSecondary, + ), + onPressed: () { + // TODO: Implement QR code scanning + }, + ), + const SizedBox(width: 8), + SecondaryButton( + width: 44, + buttonHeight: ButtonHeight.xl, + icon: CopyIcon( + width: 20, + height: 20, + color: Theme.of(context) + .extension()! + .buttonTextSecondary, + ), + onPressed: () async { + final data = await Clipboard.getData( + 'text/plain'); + if (data?.text != null) { + xpubControllers[i].text = + data!.text!; + ref + .read( + multisigCoordinatorStateProvider + .notifier) + .addCosignerXpub(data.text!); + setState( + () {}); // Trigger rebuild to update button state. + } + }, + ), + ], + ), + ], + ), + ), + + const Spacer(), + + PrimaryButton( + label: "Create multisignature account", + enabled: xpubControllers.every( + (controller) => controller.text.isNotEmpty), + onPressed: () { + // TODO. + }, + ), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/pages/wallet_view/multisig_setup_view/multisig_setup_view.dart b/lib/pages/wallet_view/multisig_coordinator_view/multisig_setup_view.dart similarity index 81% rename from lib/pages/wallet_view/multisig_setup_view/multisig_setup_view.dart rename to lib/pages/wallet_view/multisig_coordinator_view/multisig_setup_view.dart index d0930b40f..d60e0c810 100644 --- a/lib/pages/wallet_view/multisig_setup_view/multisig_setup_view.dart +++ b/lib/pages/wallet_view/multisig_coordinator_view/multisig_setup_view.dart @@ -15,6 +15,7 @@ import '../../../widgets/custom_buttons/blue_text_button.dart'; import '../../../widgets/desktop/primary_button.dart'; import '../../../widgets/dialogs/simple_mobile_dialog.dart'; import '../../../widgets/stack_dialog.dart'; +import 'multisig_coordinator_view.dart'; final multisigSetupStateProvider = StateNotifierProvider((ref) { @@ -110,8 +111,13 @@ class MultisigSetupState extends StateNotifier { class MultisigSetupView extends ConsumerStatefulWidget { const MultisigSetupView({ super.key, + this.totalCosigners, + this.threshold, }); + final int? totalCosigners; + final int? threshold; + static const String routeName = "/multisigSetup"; @override @@ -119,81 +125,25 @@ class MultisigSetupView extends ConsumerStatefulWidget { } class _MultisigSetupViewState extends ConsumerState { - // bool _isNfcAvailable = false; - // String _nfcStatus = 'Checking NFC availability...'; - @override void initState() { super.initState(); - // _checkNfcAvailability(); - } - // Future _checkNfcAvailability() async { - // try { - // final availability = await NfcManager.instance.isAvailable(); - // setState(() { - // _isNfcAvailable = availability; - // _nfcStatus = _isNfcAvailable - // ? 'NFC is available' - // : 'NFC is not available on this device'; - // }); - // } catch (e) { - // setState(() { - // _nfcStatus = 'Error checking NFC: $e'; - // _isNfcAvailable = false; - // }); - // } - // } - // - // Future _startNfcSession() async { - // if (!_isNfcAvailable) return; - // - // setState(() => _nfcStatus = 'Ready to exchange information...'); - // - // try { - // await NfcManager.instance.startSession( - // onDiscovered: (tag) async { - // try { - // final ndef = Ndef.from(tag); - // - // if (ndef == null) { - // setState(() => _nfcStatus = 'Tag is not NDEF compatible'); - // return; - // } - // - // final setupData = ref.watch(multisigSetupStateProvider); - // - // if (ndef.isWritable) { - // final message = NdefMessage([ - // NdefRecord.createMime( - // 'application/x-multisig-setup', - // Uint8List.fromList( - // utf8.encode(jsonEncode(setupData.toJson()))), - // ), - // ]); - // - // try { - // await ndef.write(message); - // setState( - // () => _nfcStatus = 'Configuration shared successfully'); - // } catch (e) { - // setState( - // () => _nfcStatus = 'Failed to share configuration: $e'); - // } - // } - // - // await NfcManager.instance.stopSession(); - // } catch (e) { - // setState(() => _nfcStatus = 'Error during NFC exchange: $e'); - // await NfcManager.instance.stopSession(); - // } - // }, - // ); - // } catch (e) { - // setState(() => _nfcStatus = 'Error: $e'); - // await NfcManager.instance.stopSession(); - // } - // } + // Initialize participants count if provided. + if (widget.totalCosigners != null) { + _participantsCount = widget.totalCosigners!; + _participantsController.text = widget.totalCosigners!.toString(); + // Initialize the controllers list. + for (int i = 0; i < widget.totalCosigners!; i++) { + controllers.add(TextEditingController()); + } + } + + // Initialize threshold if provided. + if (widget.threshold != null) { + _thresholdController.text = widget.threshold!.toString(); + } + } /// Displays a short explanation dialog about musig. Future _showMultisigInfoDialog() async { @@ -557,7 +507,7 @@ class _MultisigSetupViewState extends ConsumerState { // TODO: Push button to bottom of page. PrimaryButton( - label: "Create multisignature account", + label: "Continue", onPressed: () async { if (FocusScope.of(context).hasFocus) { FocusScope.of(context).unfocus(); @@ -575,34 +525,15 @@ class _MultisigSetupViewState extends ConsumerState { ); } - // TODO: Adapt the FROST config steps UI. - // final config = Frost.createMultisigConfig( - // name: controllers.first.text.trim(), - // threshold: int.parse(_thresholdController.text), - // participants: - // controllers.map((e) => e.text.trim()).toList(), - // ); - // - // ref.read(pFrostMyName.notifier).state = - // controllers.first.text.trim(); - // ref.read(pFrostMultisigConfig.notifier).state = config; - // - // ref.read(pFrostScaffoldArgs.state).state = ( - // info: ( - // walletName: widget.walletName, - // frostCurrency: widget.frostCurrency, - // ), - // walletId: null, - // stepRoutes: FrostRouteGenerator.createNewConfigStepRoutes, - // frostInterruptionDialogType: - // FrostInterruptionDialogType.walletCreation, - // parentNav: Navigator.of(context), - // callerRouteName: CreateNewFrostMsWalletView.routeName, - // ); - // - // await Navigator.of(context).pushNamed( - // FrostStepScaffold.routeName, - // ); + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => MultisigCoordinatorView( + totalCosigners: + int.parse(_participantsController.text), + threshold: int.parse(_thresholdController.text), + ), + ), + ); }, ), ], diff --git a/lib/pages/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart index 71517dc6f..51753bb20 100644 --- a/lib/pages/wallet_view/wallet_view.dart +++ b/lib/pages/wallet_view/wallet_view.dart @@ -98,7 +98,7 @@ import '../settings_views/wallet_settings_view/wallet_network_settings_view/wall import '../settings_views/wallet_settings_view/wallet_settings_view.dart'; import '../special/firo_rescan_recovery_error_dialog.dart'; import '../token_view/my_tokens_view.dart'; -import 'multisig_setup_view/multisig_setup_view.dart'; +import 'multisig_coordinator_view/multisig_setup_view.dart'; import 'sub_widgets/transactions_list.dart'; import 'sub_widgets/wallet_summary.dart'; import 'transaction_views/all_transactions_view.dart'; diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 5db2b8632..af82121b2 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -11,7 +11,8 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:isar/isar.dart'; -import 'package:stackwallet/pages/wallet_view/multisig_setup_view/multisig_setup_view.dart'; +import 'package:stackwallet/pages/wallet_view/multisig_coordinator_view/multisig_coordinator_view.dart'; +import 'package:stackwallet/pages/wallet_view/multisig_coordinator_view/multisig_setup_view.dart'; import 'package:tuple/tuple.dart'; import 'models/add_wallet_list_entity/add_wallet_list_entity.dart'; @@ -2157,13 +2158,41 @@ class RouteGenerator { return _routeError("${settings.name} invalid args: ${args.toString()}"); case MultisigSetupView.routeName: - return getRoute( - shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => const MultisigSetupView(), - settings: RouteSettings( - name: settings.name, - ), - ); + if (args is Tuple2) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => MultisigSetupView( + totalCosigners: args.item1, + threshold: args.item2, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } else { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => const MultisigSetupView(), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case MultisigCoordinatorView.routeName: + if (args is Tuple2) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => MultisigCoordinatorView( + totalCosigners: args.item1, + threshold: args.item2, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } return _routeError("${settings.name} invalid args: ${args.toString()}"); // == Desktop specific routes ============================================ diff --git a/lib/wallets/crypto_currency/coins/bitcoin.dart b/lib/wallets/crypto_currency/coins/bitcoin.dart index 35a9cf8f0..93d6eb43b 100644 --- a/lib/wallets/crypto_currency/coins/bitcoin.dart +++ b/lib/wallets/crypto_currency/coins/bitcoin.dart @@ -6,12 +6,16 @@ import '../../../utilities/amount/amount.dart'; import '../../../utilities/default_nodes.dart'; import '../../../utilities/enums/derive_path_type_enum.dart'; import '../crypto_currency.dart'; +import '../interfaces/bip48_currency_interface.dart'; import '../interfaces/electrumx_currency_interface.dart'; import '../interfaces/paynym_currency_interface.dart'; import '../intermediate/bip39_hd_currency.dart'; class Bitcoin extends Bip39HDCurrency - with ElectrumXCurrencyInterface, PaynymCurrencyInterface { + with + ElectrumXCurrencyInterface, + PaynymCurrencyInterface, + BIP48CurrencyInterface { Bitcoin(super.network) { _idMain = "bitcoin"; _uriScheme = "bitcoin"; diff --git a/lib/wallets/wallet/impl/bip48_wallet.dart b/lib/wallets/wallet/impl/bip48_wallet.dart index c91733729..68dfba94a 100644 --- a/lib/wallets/wallet/impl/bip48_wallet.dart +++ b/lib/wallets/wallet/impl/bip48_wallet.dart @@ -273,6 +273,8 @@ class BIP48Wallet extends Wallet TransactionType type; TransactionSubType subType = TransactionSubType.none; + // Will BIP48 wallets enjoy BIP47 compatibility? We should add vectors + // for this if so--do any wallets implement such functionality? if (outputs.length > 1 && inputs.isNotEmpty) { for (int i = 0; i < outputs.length; i++) { final List? scriptChunks = @@ -465,14 +467,13 @@ class BIP48Wallet extends Wallet @override Future prepareSend({required TxData txData}) { - // TODO: implement prepareSendpu + // TODO: implement prepareSend throw UnimplementedError(); } @override Future recover({ required bool isRescan, - String? serializedKeys, String? multisigConfig, }) async { // TODO.