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
This commit is contained in:
sneurlax 2024-12-29 16:02:29 -06:00
parent 47525fb301
commit 53901ef6a5
7 changed files with 450 additions and 112 deletions

View file

@ -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<MultisigCoordinatorState, MultisigCoordinatorData>(
(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<String> cosignerXpubs;
MultisigCoordinatorData copyWith({
int? threshold,
int? totalCosigners,
int? coinType,
int? accountIndex,
MultisigScriptType? scriptType,
List<String>? 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<String, dynamic> toJson() => {
'threshold': threshold,
'totalCosigners': totalCosigners,
'coinType': coinType,
'accountIndex': accountIndex,
'scriptType': scriptType.index,
'cosignerXpubs': cosignerXpubs,
};
factory MultisigCoordinatorData.fromJson(Map<String, dynamic> 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<String>(),
);
}
}
enum MultisigScriptType {
legacy, // P2SH.
segwit, // P2SH-P2WSH.
nativeSegwit, // P2WSH.
}
class MultisigCoordinatorState extends StateNotifier<MultisigCoordinatorData> {
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<MultisigCoordinatorView> createState() =>
_MultisigSetupViewState();
}
class _MultisigSetupViewState extends ConsumerState<MultisigCoordinatorView> {
final List<TextEditingController> 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<void> _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<void> _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<StackColors>()!.background,
appBar: AppBar(
automaticallyImplyLeading: false,
leading: AppBarBackButton(
onPressed: () async {
if (FocusScope.of(context).hasFocus) {
FocusScope.of(context).unfocus();
await Future<void>.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<StackColors>()!
.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<StackColors>()!
.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<StackColors>()!
.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.
},
),
],
),
),
),
),
);
},
),
),
),
);
}
}

View file

@ -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<MultisigSetupState, MultisigSetupData>((ref) {
@ -110,8 +111,13 @@ class MultisigSetupState extends StateNotifier<MultisigSetupData> {
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<MultisigSetupView> {
// bool _isNfcAvailable = false;
// String _nfcStatus = 'Checking NFC availability...';
@override
void initState() {
super.initState();
// _checkNfcAvailability();
}
// Future<void> _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<void> _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<void> _showMultisigInfoDialog() async {
@ -557,7 +507,7 @@ class _MultisigSetupViewState extends ConsumerState<MultisigSetupView> {
// 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<MultisigSetupView> {
);
}
// 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<void>(
builder: (context) => MultisigCoordinatorView(
totalCosigners:
int.parse(_participantsController.text),
threshold: int.parse(_thresholdController.text),
),
),
);
},
),
],

View file

@ -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';

View file

@ -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<int?, int?>) {
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<int, int>) {
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 ============================================

View file

@ -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";

View file

@ -273,6 +273,8 @@ class BIP48Wallet<T extends Bip39HDCurrency> extends Wallet<T>
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<String>? scriptChunks =
@ -465,14 +467,13 @@ class BIP48Wallet<T extends Bip39HDCurrency> extends Wallet<T>
@override
Future<TxData> prepareSend({required TxData txData}) {
// TODO: implement prepareSendpu
// TODO: implement prepareSend
throw UnimplementedError();
}
@override
Future<void> recover({
required bool isRescan,
String? serializedKeys,
String? multisigConfig,
}) async {
// TODO.