WIP frost send

This commit is contained in:
julian 2024-01-23 18:33:40 -06:00
parent 911837b265
commit 444afb88ae
12 changed files with 2746 additions and 12 deletions

View file

@ -0,0 +1,405 @@
import 'package:barcode_scan2/barcode_scan2.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:stackwallet/pages/send_view/frost_ms/frost_continue_sign_config_view.dart';
import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart';
import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart';
import 'package:stackwallet/providers/global/wallets_provider.dart';
import 'package:stackwallet/services/coins/bitcoin/frost_wallet.dart';
import 'package:stackwallet/services/frost.dart';
import 'package:stackwallet/themes/stack_colors.dart';
import 'package:stackwallet/utilities/constants.dart';
import 'package:stackwallet/utilities/logger.dart';
import 'package:stackwallet/utilities/text_styles.dart';
import 'package:stackwallet/utilities/util.dart';
import 'package:stackwallet/widgets/background.dart';
import 'package:stackwallet/widgets/conditional_parent.dart';
import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart';
import 'package:stackwallet/widgets/custom_buttons/simple_copy_button.dart';
import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart';
import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart';
import 'package:stackwallet/widgets/desktop/primary_button.dart';
import 'package:stackwallet/widgets/detail_item.dart';
import 'package:stackwallet/widgets/icon_widgets/clipboard_icon.dart';
import 'package:stackwallet/widgets/icon_widgets/qrcode_icon.dart';
import 'package:stackwallet/widgets/icon_widgets/x_icon.dart';
import 'package:stackwallet/widgets/stack_dialog.dart';
import 'package:stackwallet/widgets/stack_text_field.dart';
import 'package:stackwallet/widgets/textfield_icon_button.dart';
class FrostAttemptSignConfigView extends ConsumerStatefulWidget {
const FrostAttemptSignConfigView({
super.key,
required this.walletId,
});
static const String routeName = "/frostAttemptSignConfigView";
final String walletId;
@override
ConsumerState<FrostAttemptSignConfigView> createState() =>
_FrostAttemptSignConfigViewState();
}
class _FrostAttemptSignConfigViewState
extends ConsumerState<FrostAttemptSignConfigView> {
final List<TextEditingController> controllers = [];
final List<FocusNode> focusNodes = [];
late final String myName;
late final List<String> participantsWithoutMe;
late final String myPreprocess;
late final int myIndex;
late final int threshold;
final List<bool> fieldIsEmptyFlags = [];
bool hasEnoughPreprocesses() {
// own preprocess is not included in controllers and must be set here
int count = 1;
for (final controller in controllers) {
if (controller.text.isNotEmpty) {
count++;
}
}
return count >= threshold;
}
@override
void initState() {
final wallet = ref
.read(walletsChangeNotifierProvider)
.getManager(widget.walletId)
.wallet as FrostWallet;
myName = wallet.myName;
threshold = wallet.threshold;
participantsWithoutMe = wallet.participants;
myIndex = participantsWithoutMe.indexOf(wallet.myName);
myPreprocess = ref.read(pFrostAttemptSignData.state).state!.preprocess;
participantsWithoutMe.removeAt(myIndex);
for (int i = 0; i < participantsWithoutMe.length; i++) {
controllers.add(TextEditingController());
focusNodes.add(FocusNode());
fieldIsEmptyFlags.add(true);
}
super.initState();
}
@override
void dispose() {
for (int i = 0; i < controllers.length; i++) {
controllers[i].dispose();
}
for (int i = 0; i < focusNodes.length; i++) {
focusNodes[i].dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return ConditionalParent(
condition: Util.isDesktop,
builder: (child) => DesktopScaffold(
background: Theme.of(context).extension<StackColors>()!.background,
appBar: const DesktopAppBar(
isCompactHeight: false,
leading: AppBarBackButton(),
),
body: SizedBox(
width: 480,
child: child,
),
),
child: ConditionalParent(
condition: !Util.isDesktop,
builder: (child) => Background(
child: Scaffold(
backgroundColor:
Theme.of(context).extension<StackColors>()!.background,
appBar: AppBar(
leading: AppBarBackButton(
onPressed: () {
Navigator.of(context).pop();
},
),
title: Text(
"Preprocesses",
style: STextStyles.navBarTitle(context),
),
),
body: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
),
child: IntrinsicHeight(
child: Padding(
padding: const EdgeInsets.all(16),
child: child,
),
),
),
);
},
),
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 220,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
QrImageView(
data: myPreprocess,
size: 220,
backgroundColor:
Theme.of(context).extension<StackColors>()!.background,
foregroundColor: Theme.of(context)
.extension<StackColors>()!
.accentColorDark,
),
],
),
),
const _Div(),
DetailItem(
title: "My name",
detail: myName,
),
const _Div(),
DetailItem(
title: "My preprocess",
detail: myPreprocess,
button: Util.isDesktop
? IconCopyButton(
data: myPreprocess,
)
: SimpleCopyButton(
data: myPreprocess,
),
),
const _Div(),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (int i = 0; i < participantsWithoutMe.length; i++)
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
),
child: TextField(
key: Key("frostPreprocessesTextFieldKey_$i"),
controller: controllers[i],
focusNode: focusNodes[i],
readOnly: false,
autocorrect: false,
enableSuggestions: false,
style: STextStyles.field(context),
onChanged: (_) {
setState(() {
fieldIsEmptyFlags[i] =
controllers[i].text.isEmpty;
});
},
decoration: standardInputDecoration(
"Enter ${participantsWithoutMe[i]}'s preprocess",
focusNodes[i],
context,
).copyWith(
contentPadding: const EdgeInsets.only(
left: 16,
top: 6,
bottom: 8,
right: 5,
),
suffixIcon: Padding(
padding: fieldIsEmptyFlags[i]
? const EdgeInsets.only(right: 8)
: const EdgeInsets.only(right: 0),
child: UnconstrainedBox(
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceAround,
children: [
!fieldIsEmptyFlags[i]
? TextFieldIconButton(
semanticsLabel:
"Clear Button. Clears The Preprocess Field Input.",
key: Key(
"frostPreprocessesClearButtonKey_$i",
),
onTap: () {
controllers[i].text = "";
setState(() {
fieldIsEmptyFlags[i] = true;
});
},
child: const XIcon(),
)
: TextFieldIconButton(
semanticsLabel:
"Paste Button. Pastes From Clipboard To Preprocess Field Input.",
key: Key(
"frostPreprocessesPasteButtonKey_$i",
),
onTap: () async {
final ClipboardData? data =
await Clipboard.getData(
Clipboard.kTextPlain);
if (data?.text != null &&
data!.text!.isNotEmpty) {
controllers[i].text =
data.text!.trim();
}
setState(() {
fieldIsEmptyFlags[i] =
controllers[i]
.text
.isEmpty;
});
},
child: fieldIsEmptyFlags[i]
? const ClipboardIcon()
: const XIcon(),
),
if (fieldIsEmptyFlags[i])
TextFieldIconButton(
semanticsLabel:
"Scan QR Button. Opens Camera For Scanning QR Code.",
key: Key(
"frostPreprocessesScanQrButtonKey_$i",
),
onTap: () async {
try {
if (FocusScope.of(context)
.hasFocus) {
FocusScope.of(context)
.unfocus();
await Future<void>.delayed(
const Duration(
milliseconds: 75));
}
final qrResult =
await BarcodeScanner.scan();
controllers[i].text =
qrResult.rawContent;
setState(() {
fieldIsEmptyFlags[i] =
controllers[i].text.isEmpty;
});
} on PlatformException catch (e, s) {
Logging.instance.log(
"Failed to get camera permissions while trying to scan qr code: $e\n$s",
level: LogLevel.Warning,
);
}
},
child: const QrCodeIcon(),
)
],
),
),
),
),
),
),
),
],
),
],
),
if (!Util.isDesktop) const Spacer(),
const _Div(),
PrimaryButton(
label: "Continue signing",
enabled: hasEnoughPreprocesses(),
onPressed: () async {
// collect Preprocess strings (not including my own)
final preprocesses = controllers.map((e) => e.text).toList();
// collect participants who are involved in this transaction
final List<String> requiredParticipantsUnordered = [];
for (int i = 0; i < participantsWithoutMe.length; i++) {
if (preprocesses[i].isNotEmpty) {
requiredParticipantsUnordered.add(participantsWithoutMe[i]);
}
}
ref.read(pFrostSelectParticipantsUnordered.notifier).state =
requiredParticipantsUnordered;
// insert an empty string at my index
preprocesses.insert(myIndex, "");
try {
ref.read(pFrostContinueSignData.notifier).state =
Frost.continueSigning(
machinePtr:
ref.read(pFrostAttemptSignData.state).state!.machinePtr,
preprocesses: preprocesses,
);
await Navigator.of(context).pushNamed(
FrostContinueSignView.routeName,
arguments: widget.walletId,
);
} catch (e, s) {
Logging.instance.log(
"$e\n$s",
level: LogLevel.Fatal,
);
return await showDialog<void>(
context: context,
builder: (_) => StackOkDialog(
title: "Failed to continue signing",
desktopPopRootNavigator: Util.isDesktop,
),
);
}
},
),
],
),
),
);
}
}
class _Div extends StatelessWidget {
const _Div({super.key});
@override
Widget build(BuildContext context) {
return const SizedBox(
height: 12,
);
}
}

View file

@ -0,0 +1,206 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart';
import 'package:stackwallet/pages/wallet_view/wallet_view.dart';
import 'package:stackwallet/pages_desktop_specific/my_stack_view/my_stack_view.dart';
import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart';
import 'package:stackwallet/providers/global/wallets_provider.dart';
import 'package:stackwallet/themes/stack_colors.dart';
import 'package:stackwallet/utilities/logger.dart';
import 'package:stackwallet/utilities/show_loading.dart';
import 'package:stackwallet/utilities/text_styles.dart';
import 'package:stackwallet/utilities/util.dart';
import 'package:stackwallet/widgets/background.dart';
import 'package:stackwallet/widgets/conditional_parent.dart';
import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart';
import 'package:stackwallet/widgets/custom_buttons/simple_copy_button.dart';
import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart';
import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart';
import 'package:stackwallet/widgets/desktop/primary_button.dart';
import 'package:stackwallet/widgets/detail_item.dart';
import 'package:stackwallet/widgets/stack_dialog.dart';
class FrostCompleteSignView extends ConsumerStatefulWidget {
const FrostCompleteSignView({
super.key,
required this.walletId,
});
static const String routeName = "/frostCompleteSignView";
final String walletId;
@override
ConsumerState<FrostCompleteSignView> createState() =>
_FrostCompleteSignViewState();
}
class _FrostCompleteSignViewState extends ConsumerState<FrostCompleteSignView> {
bool _broadcastLock = false;
@override
Widget build(BuildContext context) {
return ConditionalParent(
condition: Util.isDesktop,
builder: (child) => DesktopScaffold(
background: Theme.of(context).extension<StackColors>()!.background,
appBar: const DesktopAppBar(
isCompactHeight: false,
leading: AppBarBackButton(),
),
body: SizedBox(
width: 480,
child: child,
),
),
child: ConditionalParent(
condition: !Util.isDesktop,
builder: (child) => Background(
child: Scaffold(
backgroundColor:
Theme.of(context).extension<StackColors>()!.background,
appBar: AppBar(
leading: AppBarBackButton(
onPressed: () {
Navigator.of(context).pop();
},
),
title: Text(
"Preview transaction",
style: STextStyles.navBarTitle(context),
),
),
body: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
),
child: IntrinsicHeight(
child: Padding(
padding: const EdgeInsets.all(16),
child: child,
),
),
),
);
},
),
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 220,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
QrImageView(
data: ref.watch(pFrostTxData.state).state!.raw!,
size: 220,
backgroundColor:
Theme.of(context).extension<StackColors>()!.background,
foregroundColor: Theme.of(context)
.extension<StackColors>()!
.accentColorDark,
),
],
),
),
const _Div(),
DetailItem(
title: "Raw transaction hex",
detail: ref.watch(pFrostTxData.state).state!.raw!,
button: Util.isDesktop
? IconCopyButton(
data: ref.watch(pFrostTxData.state).state!.raw!,
)
: SimpleCopyButton(
data: ref.watch(pFrostTxData.state).state!.raw!,
),
),
const _Div(),
if (!Util.isDesktop) const Spacer(),
const _Div(),
PrimaryButton(
label: "Broadcast Transaction",
onPressed: () async {
if (_broadcastLock) {
return;
}
_broadcastLock = true;
try {
Exception? ex;
final txData = await showLoading(
whileFuture: ref
.read(walletsChangeNotifierProvider)
.getManager(widget.walletId)
.confirmSend(
txData: ref.read(pFrostTxData.state).state!,
),
context: context,
message: "Broadcasting transaction to network",
isDesktop: Util.isDesktop,
onException: (e) {
ex = e;
},
);
if (ex != null) {
throw ex!;
}
if (mounted) {
if (txData != null) {
ref.read(pFrostTxData.state).state = txData;
Navigator.of(context).popUntil(
ModalRoute.withName(
Util.isDesktop
? MyStackView.routeName
: WalletView.routeName,
),
);
}
}
} catch (e, s) {
Logging.instance.log(
"$e\n$s",
level: LogLevel.Fatal,
);
return await showDialog<void>(
context: context,
builder: (_) => StackOkDialog(
title: "Broadcast error",
message: e.toString(),
desktopPopRootNavigator: Util.isDesktop,
),
);
} finally {
_broadcastLock = false;
}
},
),
],
),
),
);
}
}
class _Div extends StatelessWidget {
const _Div({super.key});
@override
Widget build(BuildContext context) {
return const SizedBox(
height: 12,
);
}
}

View file

@ -0,0 +1,445 @@
import 'package:barcode_scan2/barcode_scan2.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:stackwallet/pages/send_view/frost_ms/frost_complete_sign_view.dart';
import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart';
import 'package:stackwallet/pages/wallet_view/wallet_view.dart';
import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/desktop_wallet_view.dart';
import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart';
import 'package:stackwallet/providers/global/wallets_provider.dart';
import 'package:stackwallet/services/coins/bitcoin/frost_wallet.dart';
import 'package:stackwallet/services/frost.dart';
import 'package:stackwallet/themes/stack_colors.dart';
import 'package:stackwallet/utilities/constants.dart';
import 'package:stackwallet/utilities/logger.dart';
import 'package:stackwallet/utilities/text_styles.dart';
import 'package:stackwallet/utilities/util.dart';
import 'package:stackwallet/widgets/background.dart';
import 'package:stackwallet/widgets/conditional_parent.dart';
import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart';
import 'package:stackwallet/widgets/custom_buttons/simple_copy_button.dart';
import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart';
import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart';
import 'package:stackwallet/widgets/desktop/primary_button.dart';
import 'package:stackwallet/widgets/detail_item.dart';
import 'package:stackwallet/widgets/dialogs/frost_interruption_dialog.dart';
import 'package:stackwallet/widgets/icon_widgets/clipboard_icon.dart';
import 'package:stackwallet/widgets/icon_widgets/qrcode_icon.dart';
import 'package:stackwallet/widgets/icon_widgets/x_icon.dart';
import 'package:stackwallet/widgets/stack_dialog.dart';
import 'package:stackwallet/widgets/stack_text_field.dart';
import 'package:stackwallet/widgets/textfield_icon_button.dart';
class FrostContinueSignView extends ConsumerStatefulWidget {
const FrostContinueSignView({
super.key,
required this.walletId,
});
static const String routeName = "/frostContinueSignView";
final String walletId;
@override
ConsumerState<FrostContinueSignView> createState() =>
_FrostContinueSignViewState();
}
class _FrostContinueSignViewState extends ConsumerState<FrostContinueSignView> {
final List<TextEditingController> controllers = [];
final List<FocusNode> focusNodes = [];
late final String myName;
late final List<String> participantsWithoutMe;
late final List<String> participantsAll;
late final String myShare;
late final int myIndex;
final List<bool> fieldIsEmptyFlags = [];
@override
void initState() {
final wallet = ref
.read(walletsChangeNotifierProvider)
.getManager(widget.walletId)
.wallet as FrostWallet;
myName = wallet.myName;
participantsAll = wallet.participants;
myIndex = wallet.participants.indexOf(wallet.myName);
myShare = ref.read(pFrostContinueSignData.state).state!.share;
participantsWithoutMe = wallet.participants
.toSet()
.intersection(
ref.read(pFrostSelectParticipantsUnordered.state).state!.toSet())
.toList();
participantsWithoutMe.remove(myName);
for (int i = 0; i < participantsWithoutMe.length; i++) {
controllers.add(TextEditingController());
focusNodes.add(FocusNode());
fieldIsEmptyFlags.add(true);
}
super.initState();
}
@override
void dispose() {
for (int i = 0; i < controllers.length; i++) {
controllers[i].dispose();
}
for (int i = 0; i < focusNodes.length; i++) {
focusNodes[i].dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
await showDialog<void>(
context: context,
builder: (_) => FrostInterruptionDialog(
type: FrostInterruptionDialogType.transactionCreation,
popUntilOnYesRouteName: Util.isDesktop
? DesktopWalletView.routeName
: WalletView.routeName,
),
);
return false;
},
child: ConditionalParent(
condition: Util.isDesktop,
builder: (child) => DesktopScaffold(
background: Theme.of(context).extension<StackColors>()!.background,
appBar: DesktopAppBar(
isCompactHeight: false,
leading: AppBarBackButton(
onPressed: () async {
await showDialog<void>(
context: context,
builder: (_) => const FrostInterruptionDialog(
type: FrostInterruptionDialogType.transactionCreation,
popUntilOnYesRouteName: DesktopWalletView.routeName,
),
);
},
),
),
body: SizedBox(
width: 480,
child: child,
),
),
child: ConditionalParent(
condition: !Util.isDesktop,
builder: (child) => Background(
child: Scaffold(
backgroundColor:
Theme.of(context).extension<StackColors>()!.background,
appBar: AppBar(
leading: AppBarBackButton(
onPressed: () async {
await showDialog<void>(
context: context,
builder: (_) => const FrostInterruptionDialog(
type: FrostInterruptionDialogType.transactionCreation,
popUntilOnYesRouteName: WalletView.routeName,
),
);
},
),
title: Text(
"Shares",
style: STextStyles.navBarTitle(context),
),
),
body: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
),
child: IntrinsicHeight(
child: Padding(
padding: const EdgeInsets.all(16),
child: child,
),
),
),
);
},
),
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 220,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
QrImageView(
data: myShare,
size: 220,
backgroundColor: Theme.of(context)
.extension<StackColors>()!
.background,
foregroundColor: Theme.of(context)
.extension<StackColors>()!
.accentColorDark,
),
],
),
),
const _Div(),
DetailItem(
title: "My name",
detail: myName,
),
const _Div(),
DetailItem(
title: "My shares",
detail: myShare,
button: Util.isDesktop
? IconCopyButton(
data: myShare,
)
: SimpleCopyButton(
data: myShare,
),
),
const _Div(),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (int i = 0; i < participantsWithoutMe.length; i++)
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
),
child: TextField(
key: Key("frostSharesTextFieldKey_$i"),
controller: controllers[i],
focusNode: focusNodes[i],
readOnly: false,
autocorrect: false,
enableSuggestions: false,
style: STextStyles.field(context),
decoration: standardInputDecoration(
"Enter ${participantsWithoutMe[i]}'s share",
focusNodes[i],
context,
).copyWith(
contentPadding: const EdgeInsets.only(
left: 16,
top: 6,
bottom: 8,
right: 5,
),
suffixIcon: Padding(
padding: fieldIsEmptyFlags[i]
? const EdgeInsets.only(right: 8)
: const EdgeInsets.only(right: 0),
child: UnconstrainedBox(
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceAround,
children: [
!fieldIsEmptyFlags[i]
? TextFieldIconButton(
semanticsLabel:
"Clear Button. Clears "
"The Share Field Input.",
key: Key(
"frostSharesClearButtonKey_$i",
),
onTap: () {
controllers[i].text = "";
setState(() {
fieldIsEmptyFlags[i] = true;
});
},
child: const XIcon(),
)
: TextFieldIconButton(
semanticsLabel:
"Paste Button. Pastes From "
"Clipboard To Share Field Input.",
key: Key(
"frostSharesPasteButtonKey_$i"),
onTap: () async {
final ClipboardData? data =
await Clipboard.getData(
Clipboard.kTextPlain);
if (data?.text != null &&
data!.text!.isNotEmpty) {
controllers[i].text =
data.text!.trim();
}
setState(() {
fieldIsEmptyFlags[i] =
controllers[i]
.text
.isEmpty;
});
},
child: fieldIsEmptyFlags[i]
? const ClipboardIcon()
: const XIcon(),
),
if (fieldIsEmptyFlags[i])
TextFieldIconButton(
semanticsLabel:
"Scan QR Button. Opens Camera "
"For Scanning QR Code.",
key: Key(
"frostSharesScanQrButtonKey_$i",
),
onTap: () async {
try {
if (FocusScope.of(context)
.hasFocus) {
FocusScope.of(context)
.unfocus();
await Future<void>.delayed(
const Duration(
milliseconds: 75));
}
final qrResult =
await BarcodeScanner.scan();
controllers[i].text =
qrResult.rawContent;
setState(() {
fieldIsEmptyFlags[i] =
controllers[i]
.text
.isEmpty;
});
} on PlatformException catch (e, s) {
Logging.instance.log(
"Failed to get camera permissions "
"while trying to scan qr code: $e\n$s",
level: LogLevel.Warning,
);
}
},
child: const QrCodeIcon(),
)
],
),
),
),
),
),
),
),
],
),
],
),
if (!Util.isDesktop) const Spacer(),
const _Div(),
PrimaryButton(
label: "Complete signing",
onPressed: () async {
// check for empty shares
if (controllers
.map((e) => e.text.isEmpty)
.reduce((value, element) => value |= element)) {
return await showDialog<void>(
context: context,
builder: (_) => StackOkDialog(
title: "Missing Shares",
desktopPopRootNavigator: Util.isDesktop,
),
);
}
// collect Share strings
final sharesCollected =
controllers.map((e) => e.text).toList();
final List<String> shares = [];
for (final participant in participantsAll) {
if (participantsWithoutMe.contains(participant)) {
shares.add(sharesCollected[
participantsWithoutMe.indexOf(participant)]);
} else {
shares.add("");
}
}
try {
final rawTx = Frost.completeSigning(
machinePtr: ref
.read(pFrostContinueSignData.state)
.state!
.machinePtr,
shares: shares,
);
ref.read(pFrostTxData.state).state =
ref.read(pFrostTxData.state).state!.copyWith(
raw: rawTx,
);
await Navigator.of(context).pushNamed(
FrostCompleteSignView.routeName,
arguments: widget.walletId,
);
} catch (e, s) {
Logging.instance.log(
"$e\n$s",
level: LogLevel.Fatal,
);
return await showDialog<void>(
context: context,
builder: (_) => StackOkDialog(
title: "Failed to complete signing process",
desktopPopRootNavigator: Util.isDesktop,
),
);
}
},
),
],
),
),
),
);
}
}
class _Div extends StatelessWidget {
const _Div({super.key});
@override
Widget build(BuildContext context) {
return const SizedBox(
height: 12,
);
}
}

View file

@ -0,0 +1,180 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:stackwallet/pages/send_view/frost_ms/frost_attempt_sign_config_view.dart';
import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart';
import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart';
import 'package:stackwallet/providers/global/wallets_provider.dart';
import 'package:stackwallet/themes/stack_colors.dart';
import 'package:stackwallet/utilities/logger.dart';
import 'package:stackwallet/utilities/text_styles.dart';
import 'package:stackwallet/utilities/util.dart';
import 'package:stackwallet/wallets/wallet/impl/bitcoin_frost_wallet.dart';
import 'package:stackwallet/widgets/background.dart';
import 'package:stackwallet/widgets/conditional_parent.dart';
import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart';
import 'package:stackwallet/widgets/custom_buttons/simple_copy_button.dart';
import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart';
import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart';
import 'package:stackwallet/widgets/desktop/primary_button.dart';
import 'package:stackwallet/widgets/detail_item.dart';
class FrostCreateSignConfigView extends ConsumerStatefulWidget {
const FrostCreateSignConfigView({
super.key,
required this.walletId,
});
static const String routeName = "/frostCreateSignConfigView";
final String walletId;
@override
ConsumerState<FrostCreateSignConfigView> createState() =>
_FrostCreateSignConfigViewState();
}
class _FrostCreateSignConfigViewState
extends ConsumerState<FrostCreateSignConfigView> {
bool _attemptSignLock = false;
Future<void> _attemptSign() async {
if (_attemptSignLock) {
return;
}
_attemptSignLock = true;
try {
final wallet =
ref.read(pWallets).getWallet(widget.walletId) as BitcoinFrostWallet;
final attemptSignRes = await wallet.frostAttemptSignConfig(
config: ref.read(pFrostTxData.state).state!.frostMSConfig!,
);
ref.read(pFrostAttemptSignData.notifier).state = attemptSignRes;
await Navigator.of(context).pushNamed(
FrostAttemptSignConfigView.routeName,
arguments: widget.walletId,
);
} catch (e, s) {
Logging.instance.log(
"$e\n$s",
level: LogLevel.Error,
);
} finally {
_attemptSignLock = false;
}
}
@override
Widget build(BuildContext context) {
return ConditionalParent(
condition: Util.isDesktop,
builder: (child) => DesktopScaffold(
background: Theme.of(context).extension<StackColors>()!.background,
appBar: const DesktopAppBar(
isCompactHeight: false,
leading: AppBarBackButton(),
),
body: SizedBox(
width: 480,
child: child,
),
),
child: ConditionalParent(
condition: !Util.isDesktop,
builder: (child) => Background(
child: Scaffold(
backgroundColor:
Theme.of(context).extension<StackColors>()!.background,
appBar: AppBar(
leading: AppBarBackButton(
onPressed: () {
Navigator.of(context).pop();
},
),
title: Text(
"Sign config",
style: STextStyles.navBarTitle(context),
),
),
body: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
),
child: IntrinsicHeight(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: child,
),
),
),
);
},
),
),
),
),
child: Column(
children: [
if (!Util.isDesktop) const Spacer(),
SizedBox(
height: MediaQuery.of(context).size.width - 32,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
QrImageView(
data: ref.watch(pFrostTxData.state).state!.frostMSConfig!,
size: MediaQuery.of(context).size.width - 32,
backgroundColor:
Theme.of(context).extension<StackColors>()!.background,
foregroundColor: Theme.of(context)
.extension<StackColors>()!
.accentColorDark,
),
],
),
),
const SizedBox(
height: 32,
),
DetailItem(
title: "Encoded config",
detail: ref.watch(pFrostTxData.state).state!.frostMSConfig!,
button: Util.isDesktop
? IconCopyButton(
data: ref.watch(pFrostTxData.state).state!.frostMSConfig!,
)
: SimpleCopyButton(
data: ref.watch(pFrostTxData.state).state!.frostMSConfig!,
),
),
SizedBox(
height: Util.isDesktop ? 64 : 16,
),
if (!Util.isDesktop)
const Spacer(
flex: 2,
),
PrimaryButton(
label: "Attempt sign",
onPressed: () {
_attemptSign();
},
),
const SizedBox(
height: 16,
),
],
),
),
);
}
}

View file

@ -0,0 +1,330 @@
import 'package:barcode_scan2/barcode_scan2.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:isar/isar.dart';
import 'package:stackwallet/models/isar/models/isar_models.dart';
import 'package:stackwallet/pages/send_view/frost_ms/frost_attempt_sign_config_view.dart';
import 'package:stackwallet/providers/db/main_db_provider.dart';
import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart';
import 'package:stackwallet/providers/global/wallets_provider.dart';
import 'package:stackwallet/services/frost.dart';
import 'package:stackwallet/themes/stack_colors.dart';
import 'package:stackwallet/utilities/constants.dart';
import 'package:stackwallet/utilities/format.dart';
import 'package:stackwallet/utilities/logger.dart';
import 'package:stackwallet/utilities/text_styles.dart';
import 'package:stackwallet/utilities/util.dart';
import 'package:stackwallet/wallets/models/tx_data.dart';
import 'package:stackwallet/wallets/wallet/impl/bitcoin_frost_wallet.dart';
import 'package:stackwallet/widgets/background.dart';
import 'package:stackwallet/widgets/conditional_parent.dart';
import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart';
import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart';
import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart';
import 'package:stackwallet/widgets/desktop/primary_button.dart';
import 'package:stackwallet/widgets/icon_widgets/clipboard_icon.dart';
import 'package:stackwallet/widgets/icon_widgets/qrcode_icon.dart';
import 'package:stackwallet/widgets/icon_widgets/x_icon.dart';
import 'package:stackwallet/widgets/stack_dialog.dart';
import 'package:stackwallet/widgets/stack_text_field.dart';
import 'package:stackwallet/widgets/textfield_icon_button.dart';
class FrostImportSignConfigView extends ConsumerStatefulWidget {
const FrostImportSignConfigView({
super.key,
required this.walletId,
});
static const String routeName = "/frostImportSignConfigView";
final String walletId;
@override
ConsumerState<FrostImportSignConfigView> createState() =>
_FrostImportSignConfigViewState();
}
class _FrostImportSignConfigViewState
extends ConsumerState<FrostImportSignConfigView> {
late final TextEditingController configFieldController;
late final FocusNode configFocusNode;
bool _configEmpty = true;
bool _attemptSignLock = false;
Future<void> _attemptSign() async {
if (_attemptSignLock) {
return;
}
_attemptSignLock = true;
try {
if (FocusScope.of(context).hasFocus) {
FocusScope.of(context).unfocus();
}
final config = configFieldController.text;
final wallet =
ref.read(pWallets).getWallet(widget.walletId) as BitcoinFrostWallet;
final data = Frost.extractDataFromSignConfig(
signConfig: config,
coin: wallet.cryptoCurrency,
);
final utxos = await ref
.read(mainDBProvider)
.getUTXOs(wallet.walletId)
.filter()
.anyOf(
data.inputs,
(q, e) => q
.txidEqualTo(Format.uint8listToString(e.hash))
.and()
.valueEqualTo(e.value)
.and()
.voutEqualTo(e.vout))
.findAll();
// TODO add more data from 'data' and display to user ?
ref.read(pFrostTxData.notifier).state = TxData(
frostMSConfig: config,
recipients: data.recipients,
utxos: utxos.toSet(),
);
final attemptSignRes = await wallet.frostAttemptSignConfig(
config: ref.read(pFrostTxData.state).state!.frostMSConfig!,
);
ref.read(pFrostAttemptSignData.notifier).state = attemptSignRes;
await Navigator.of(context).pushNamed(
FrostAttemptSignConfigView.routeName,
arguments: widget.walletId,
);
} catch (e, s) {
Logging.instance.log(
"$e\n$s",
level: LogLevel.Error,
);
await showDialog<void>(
context: context,
builder: (_) => StackOkDialog(
title: "Import and attempt sign config failed",
message: e.toString(),
desktopPopRootNavigator: Util.isDesktop,
),
);
} finally {
_attemptSignLock = false;
}
}
@override
void initState() {
configFieldController = TextEditingController();
configFocusNode = FocusNode();
super.initState();
}
@override
void dispose() {
configFieldController.dispose();
configFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ConditionalParent(
condition: Util.isDesktop,
builder: (child) => DesktopScaffold(
background: Theme.of(context).extension<StackColors>()!.background,
appBar: const DesktopAppBar(
isCompactHeight: false,
leading: AppBarBackButton(),
),
body: SizedBox(
width: 480,
child: child,
),
),
child: ConditionalParent(
condition: !Util.isDesktop,
builder: (child) => Background(
child: Scaffold(
backgroundColor:
Theme.of(context).extension<StackColors>()!.background,
appBar: AppBar(
leading: AppBarBackButton(
onPressed: () {
Navigator.of(context).pop();
},
),
title: Text(
"Import FROST sign config",
style: STextStyles.navBarTitle(context),
),
),
body: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
),
child: IntrinsicHeight(
child: Padding(
padding: const EdgeInsets.all(16),
child: child,
),
),
),
);
},
),
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(
height: 16,
),
ClipRRect(
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
),
child: TextField(
key: const Key("frConfigTextFieldKey"),
controller: configFieldController,
onChanged: (_) {
setState(() {
_configEmpty = configFieldController.text.isEmpty;
});
},
focusNode: configFocusNode,
readOnly: false,
autocorrect: false,
enableSuggestions: false,
style: STextStyles.field(context),
decoration: standardInputDecoration(
"Enter config",
configFocusNode,
context,
).copyWith(
contentPadding: const EdgeInsets.only(
left: 16,
top: 6,
bottom: 8,
right: 5,
),
suffixIcon: Padding(
padding: _configEmpty
? const EdgeInsets.only(right: 8)
: const EdgeInsets.only(right: 0),
child: UnconstrainedBox(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
!_configEmpty
? TextFieldIconButton(
semanticsLabel:
"Clear Button. Clears The Config Field.",
key: const Key("frConfigClearButtonKey"),
onTap: () {
configFieldController.text = "";
setState(() {
_configEmpty = true;
});
},
child: const XIcon(),
)
: TextFieldIconButton(
semanticsLabel:
"Paste Button. Pastes From Clipboard To Config Field Input.",
key: const Key("frConfigPasteButtonKey"),
onTap: () async {
final ClipboardData? data =
await Clipboard.getData(
Clipboard.kTextPlain);
if (data?.text != null &&
data!.text!.isNotEmpty) {
configFieldController.text =
data.text!.trim();
}
setState(() {
_configEmpty =
configFieldController.text.isEmpty;
});
},
child: _configEmpty
? const ClipboardIcon()
: const XIcon(),
),
if (_configEmpty)
TextFieldIconButton(
semanticsLabel:
"Scan QR Button. Opens Camera For Scanning QR Code.",
key: const Key("frConfigScanQrButtonKey"),
onTap: () async {
try {
if (FocusScope.of(context).hasFocus) {
FocusScope.of(context).unfocus();
await Future<void>.delayed(
const Duration(milliseconds: 75));
}
final qrResult = await BarcodeScanner.scan();
configFieldController.text =
qrResult.rawContent;
setState(() {
_configEmpty =
configFieldController.text.isEmpty;
});
} on PlatformException catch (e, s) {
Logging.instance.log(
"Failed to get camera permissions while trying to scan qr code: $e\n$s",
level: LogLevel.Warning,
);
}
},
child: const QrCodeIcon(),
)
],
),
),
),
),
),
),
const SizedBox(
height: 16,
),
if (!Util.isDesktop) const Spacer(),
const SizedBox(
height: 16,
),
PrimaryButton(
label: "Start signing",
enabled: !_configEmpty,
onPressed: () {
_attemptSign();
},
),
],
),
),
);
}
}

View file

@ -0,0 +1,602 @@
/*
* This file is part of Stack Wallet.
*
* Copyright (c) 2023 Cypher Stack
* All Rights Reserved.
* The code is distributed under GPLv3 license, see LICENSE file for details.
* Generated by Cypher Stack on 2023-05-26
*
*/
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:stackwallet/models/isar/models/isar_models.dart';
import 'package:stackwallet/pages/coin_control/coin_control_view.dart';
import 'package:stackwallet/pages/send_view/frost_ms/frost_create_sign_config_view.dart';
import 'package:stackwallet/pages/send_view/frost_ms/frost_import_sign_config_view.dart';
import 'package:stackwallet/pages/send_view/frost_ms/recipient.dart';
import 'package:stackwallet/providers/frost_wallet/frost_wallet_providers.dart';
import 'package:stackwallet/providers/providers.dart';
import 'package:stackwallet/themes/coin_icon_provider.dart';
import 'package:stackwallet/themes/stack_colors.dart';
import 'package:stackwallet/utilities/amount/amount.dart';
import 'package:stackwallet/utilities/amount/amount_formatter.dart';
import 'package:stackwallet/utilities/assets.dart';
import 'package:stackwallet/utilities/constants.dart';
import 'package:stackwallet/utilities/enums/coin_enum.dart';
import 'package:stackwallet/utilities/show_loading.dart';
import 'package:stackwallet/utilities/text_styles.dart';
import 'package:stackwallet/utilities/util.dart';
import 'package:stackwallet/wallets/isar/providers/wallet_info_provider.dart';
import 'package:stackwallet/wallets/models/tx_data.dart';
import 'package:stackwallet/wallets/wallet/impl/bitcoin_frost_wallet.dart';
import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart';
import 'package:stackwallet/widgets/background.dart';
import 'package:stackwallet/widgets/conditional_parent.dart';
import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart';
import 'package:stackwallet/widgets/custom_buttons/blue_text_button.dart';
import 'package:stackwallet/widgets/fee_slider.dart';
import 'package:stackwallet/widgets/icon_widgets/x_icon.dart';
import 'package:stackwallet/widgets/rounded_white_container.dart';
import 'package:stackwallet/widgets/stack_dialog.dart';
import 'package:stackwallet/widgets/stack_text_field.dart';
import 'package:stackwallet/widgets/textfield_icon_button.dart';
import 'package:tuple/tuple.dart';
class FrostSendView extends ConsumerStatefulWidget {
const FrostSendView({
Key? key,
required this.walletId,
required this.coin,
}) : super(key: key);
static const String routeName = "/frostSendView";
final String walletId;
final Coin coin;
@override
ConsumerState<FrostSendView> createState() => _FrostSendViewState();
}
class _FrostSendViewState extends ConsumerState<FrostSendView> {
final List<int> recipientWidgetIndexes = [0];
int _greatestWidgetIndex = 0;
late final String walletId;
late final Coin coin;
late TextEditingController noteController;
late TextEditingController onChainNoteController;
final _noteFocusNode = FocusNode();
Set<UTXO> selectedUTXOs = {};
bool _createSignLock = false;
Future<TxData> _loadingFuture() async {
final wallet = ref.read(pWallets).getWallet(walletId) as BitcoinFrostWallet;
final recipients = recipientWidgetIndexes
.map((i) => ref.read(pRecipient(i).state).state)
.map((e) => (address: e!.address, amount: e.amount!, isChange: false))
.toList(growable: false);
final txData = await wallet.frostCreateSignConfig(
txData: TxData(recipients: recipients),
changeAddress: (await wallet.getCurrentReceivingAddress())!.value,
feePerWeight: customFeeRate,
);
return txData;
}
Future<void> _createSignConfig() async {
if (_createSignLock) {
return;
}
_createSignLock = true;
try {
// wait for keyboard to disappear
FocusScope.of(context).unfocus();
await Future<void>.delayed(
const Duration(milliseconds: 100),
);
TxData? txData;
if (mounted) {
txData = await showLoading<TxData>(
whileFuture: _loadingFuture(),
context: context,
message: "Generating sign config",
isDesktop: Util.isDesktop,
onException: (e) {
throw e;
},
);
}
if (mounted && txData != null) {
ref.read(pFrostTxData.notifier).state = txData;
await Navigator.of(context).pushNamed(
FrostCreateSignConfigView.routeName,
arguments: widget.walletId,
);
}
} catch (e) {
if (mounted) {
unawaited(
showDialog<dynamic>(
context: context,
useSafeArea: false,
barrierDismissible: true,
builder: (context) {
return StackDialog(
title: "Create sign config failed",
message: e.toString(),
rightButton: TextButton(
style: Theme.of(context)
.extension<StackColors>()!
.getSecondaryEnabledButtonStyle(context),
child: Text(
"Ok",
style: STextStyles.button(context).copyWith(
color: Theme.of(context)
.extension<StackColors>()!
.accentColorDark),
),
onPressed: () {
Navigator.of(context).pop();
},
),
);
},
),
);
}
} finally {
_createSignLock = false;
}
}
int customFeeRate = 1;
void _validateRecipientFormStates() {
for (final i in recipientWidgetIndexes) {
final state = ref.read(pRecipient(i).state).state;
if (state?.amount == null || state?.address == null) {
ref.read(previewTxButtonStateProvider.notifier).state = false;
return;
}
}
ref.read(previewTxButtonStateProvider.notifier).state = true;
return;
}
@override
void initState() {
coin = widget.coin;
walletId = widget.walletId;
noteController = TextEditingController();
super.initState();
}
@override
void dispose() {
noteController.dispose();
_noteFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
debugPrint("BUILD: $runtimeType");
final wallet = ref.watch(pWallets).getWallet(walletId);
final showCoinControl = wallet is CoinControlInterface &&
ref.watch(
prefsChangeNotifierProvider.select(
(value) => value.enableCoinControl,
),
);
return ConditionalParent(
condition: !Util.isDesktop,
builder: (child) => Background(
child: Scaffold(
backgroundColor:
Theme.of(context).extension<StackColors>()!.background,
appBar: AppBar(
leading: AppBarBackButton(
onPressed: () async {
if (FocusScope.of(context).hasFocus) {
FocusScope.of(context).unfocus();
await Future<void>.delayed(const Duration(milliseconds: 50));
}
if (mounted) {
Navigator.of(context).pop();
}
},
),
title: Text(
"Send ${coin.ticker}",
style: STextStyles.navBarTitle(context),
),
actions: [
Padding(
padding: const EdgeInsets.only(
top: 10,
bottom: 10,
right: 10,
),
child: AspectRatio(
aspectRatio: 1,
child: AppBarIconButton(
semanticsLabel: "Import sign config Button.",
key: const Key("importSignConfigButtonKey"),
size: 36,
shadows: const [],
color:
Theme.of(context).extension<StackColors>()!.background,
icon: SvgPicture.asset(
Assets.svg.circlePlus,
color: Theme.of(context)
.extension<StackColors>()!
.accentColorDark,
width: 20,
height: 20,
),
onPressed: () {
Navigator.of(context).pushNamed(
FrostImportSignConfigView.routeName,
arguments: walletId,
);
},
),
),
),
],
),
body: LayoutBuilder(
builder: (builderContext, constraints) {
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(
// subtract top and bottom padding set in parent
minHeight: constraints.maxHeight,
),
child: IntrinsicHeight(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: child,
),
),
),
);
},
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!Util.isDesktop)
Container(
decoration: BoxDecoration(
color: Theme.of(context).extension<StackColors>()!.popupBG,
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
),
),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
children: [
SvgPicture.file(
File(
ref.watch(
coinIconProvider(coin),
),
),
width: 22,
height: 22,
),
const SizedBox(
width: 6,
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
ref.watch(pWalletName(walletId)),
style: STextStyles.titleBold12(context)
.copyWith(fontSize: 14),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
// const SizedBox(
// height: 2,
// ),
Text(
"Available balance",
style:
STextStyles.label(context).copyWith(fontSize: 10),
),
],
),
Util.isDesktop
? const SizedBox(
height: 24,
)
: const Spacer(),
GestureDetector(
onTap: () {
// cryptoAmountController.text = ref
// .read(pAmountFormatter(coin))
// .format(
// _cachedBalance!,
// withUnitName: false,
// );
},
child: Container(
color: Colors.transparent,
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
ref.watch(pAmountFormatter(coin)).format(ref
.watch(pWalletBalance(walletId))
.spendable),
style: STextStyles.titleBold12(context).copyWith(
fontSize: 10,
),
textAlign: TextAlign.right,
),
// Text(
// "${(manager.balance.spendable.decimal * ref.watch(
// priceAnd24hChangeNotifierProvider.select(
// (value) => value.getPrice(coin).item1,
// ),
// )).toAmount(
// fractionDigits: 2,
// ).fiatString(
// locale: locale,
// )} ${ref.watch(
// prefsChangeNotifierProvider
// .select((value) => value.currency),
// )}",
// style: STextStyles.subtitle(context).copyWith(
// fontSize: 8,
// ),
// textAlign: TextAlign.right,
// )
],
),
),
)
],
),
),
),
const SizedBox(
height: 16,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Recipients",
style: STextStyles.smallMed12(context),
textAlign: TextAlign.left,
),
CustomTextButton(
text: "Add",
onTap: () {
// used for tracking recipient forms
_greatestWidgetIndex++;
recipientWidgetIndexes.add(_greatestWidgetIndex);
setState(() {});
},
),
],
),
const SizedBox(
height: 8,
),
Column(
children: [
for (int i = 0; i < recipientWidgetIndexes.length; i++)
ConditionalParent(
condition: recipientWidgetIndexes.length > 1,
builder: (child) => Padding(
padding: const EdgeInsets.only(top: 8),
child: child,
),
child: Recipient(
key: Key(
"recipientKey_${recipientWidgetIndexes[i]}",
),
index: recipientWidgetIndexes[i],
coin: coin,
onChanged: () {
_validateRecipientFormStates();
},
remove: i == 0 && recipientWidgetIndexes.length == 1
? null
: () {
recipientWidgetIndexes.removeAt(i);
setState(() {});
},
),
),
],
),
if (showCoinControl)
const SizedBox(
height: 8,
),
if (showCoinControl)
RoundedWhiteContainer(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Coin control",
style: STextStyles.w500_14(context).copyWith(
color: Theme.of(context)
.extension<StackColors>()!
.textSubtitle1,
),
),
CustomTextButton(
text: selectedUTXOs.isEmpty
? "Select coins"
: "Selected coins (${selectedUTXOs.length})",
onTap: () async {
if (FocusScope.of(context).hasFocus) {
FocusScope.of(context).unfocus();
await Future<void>.delayed(
const Duration(milliseconds: 100),
);
}
if (mounted) {
// finally spendable = ref
// .read(walletsChangeNotifierProvider)
// .getManager(widget.walletId)
// .balance
// .spendable;
// TODO: [prio=high] make sure this coincontrol works correctly
Amount? amount;
final result = await Navigator.of(context).pushNamed(
CoinControlView.routeName,
arguments: Tuple4(
walletId,
CoinControlViewType.use,
amount,
selectedUTXOs,
),
);
if (result is Set<UTXO>) {
setState(() {
selectedUTXOs = result;
});
}
}
},
),
],
),
),
const SizedBox(
height: 12,
),
Text(
"Note (optional)",
style: STextStyles.smallMed12(context),
textAlign: TextAlign.left,
),
const SizedBox(
height: 8,
),
ClipRRect(
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
),
child: TextField(
autocorrect: Util.isDesktop ? false : true,
enableSuggestions: Util.isDesktop ? false : true,
controller: noteController,
focusNode: _noteFocusNode,
style: STextStyles.field(context),
onChanged: (_) => setState(() {}),
decoration: standardInputDecoration(
"Type something...",
_noteFocusNode,
context,
).copyWith(
suffixIcon: noteController.text.isNotEmpty
? Padding(
padding: const EdgeInsets.only(right: 0),
child: UnconstrainedBox(
child: Row(
children: [
TextFieldIconButton(
child: const XIcon(),
onTap: () async {
setState(() {
noteController.text = "";
});
},
),
],
),
),
)
: null,
),
),
),
const SizedBox(
height: 12,
),
Padding(
padding: const EdgeInsets.only(
bottom: 12,
top: 16,
),
child: FeeSlider(
coin: coin,
onSatVByteChanged: (rate) {
customFeeRate = rate;
},
),
),
Util.isDesktop
? const SizedBox(
height: 12,
)
: const Spacer(),
const SizedBox(
height: 12,
),
TextButton(
onPressed: ref.watch(previewTxButtonStateProvider.state).state
? _createSignConfig
: null,
style: ref.watch(previewTxButtonStateProvider.state).state
? Theme.of(context)
.extension<StackColors>()!
.getPrimaryEnabledButtonStyle(context)
: Theme.of(context)
.extension<StackColors>()!
.getPrimaryDisabledButtonStyle(context),
child: Text(
"Create config",
style: STextStyles.button(context),
),
),
const SizedBox(
height: 16,
),
],
),
);
}
}
final previewTxButtonStateProvider = StateProvider((_) => false);

View file

@ -0,0 +1,501 @@
import 'package:decimal/decimal.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:stackwallet/providers/global/locale_provider.dart';
import 'package:stackwallet/themes/stack_colors.dart';
import 'package:stackwallet/utilities/address_utils.dart';
import 'package:stackwallet/utilities/amount/amount.dart';
import 'package:stackwallet/utilities/amount/amount_formatter.dart';
import 'package:stackwallet/utilities/amount/amount_input_formatter.dart';
import 'package:stackwallet/utilities/amount/amount_unit.dart';
import 'package:stackwallet/utilities/barcode_scanner_interface.dart';
import 'package:stackwallet/utilities/clipboard_interface.dart';
import 'package:stackwallet/utilities/constants.dart';
import 'package:stackwallet/utilities/enums/coin_enum.dart';
import 'package:stackwallet/utilities/logger.dart';
import 'package:stackwallet/utilities/text_styles.dart';
import 'package:stackwallet/utilities/util.dart';
import 'package:stackwallet/widgets/custom_buttons/blue_text_button.dart';
import 'package:stackwallet/widgets/icon_widgets/clipboard_icon.dart';
import 'package:stackwallet/widgets/icon_widgets/qrcode_icon.dart';
import 'package:stackwallet/widgets/icon_widgets/x_icon.dart';
import 'package:stackwallet/widgets/rounded_white_container.dart';
import 'package:stackwallet/widgets/stack_text_field.dart';
import 'package:stackwallet/widgets/textfield_icon_button.dart';
//TODO: move the following two providers elsewhere
final pClipboard =
Provider<ClipboardInterface>((ref) => const ClipboardWrapper());
final pBarcodeScanner =
Provider<BarcodeScannerInterface>((ref) => const BarcodeScannerWrapper());
// final _pPrice = Provider.family<Decimal, Coin>((ref, coin) {
// return ref.watch(
// priceAnd24hChangeNotifierProvider
// .select((value) => value.getPrice(coin).item1),
// );
// });
final pRecipient =
StateProvider.family<({String address, Amount? amount})?, int>(
(ref, index) => null);
class Recipient extends ConsumerStatefulWidget {
const Recipient({
super.key,
required this.index,
required this.coin,
this.remove,
this.onChanged,
});
final int index;
final Coin coin;
final VoidCallback? remove;
final VoidCallback? onChanged;
@override
ConsumerState<Recipient> createState() => _RecipientState();
}
class _RecipientState extends ConsumerState<Recipient> {
late final TextEditingController addressController, amountController;
late final FocusNode addressFocusNode, amountFocusNode;
bool _addressIsEmpty = true;
bool _cryptoAmountChangeLock = false;
void _updateRecipientData() {
final address = addressController.text;
final amount =
ref.read(pAmountFormatter(widget.coin)).tryParse(amountController.text);
ref.read(pRecipient(widget.index).notifier).state = (
address: address,
amount: amount,
);
widget.onChanged?.call();
}
void _cryptoAmountChanged() async {
if (!_cryptoAmountChangeLock) {
Amount? cryptoAmount = ref.read(pAmountFormatter(widget.coin)).tryParse(
amountController.text,
);
if (cryptoAmount != null) {
if (ref.read(pRecipient(widget.index))?.amount != null &&
ref.read(pRecipient(widget.index))?.amount == cryptoAmount) {
return;
}
// final price = ref.read(_pPrice(widget.coin));
//
// if (price > Decimal.zero) {
// baseController.text = (cryptoAmount.decimal * price)
// .toAmount(
// fractionDigits: 2,
// )
// .fiatString(
// locale: ref.read(localeServiceChangeNotifierProvider).locale,
// );
// }
} else {
cryptoAmount = null;
// baseController.text = "";
}
_updateRecipientData();
}
}
@override
void initState() {
addressController = TextEditingController();
amountController = TextEditingController();
// baseController = TextEditingController();
addressFocusNode = FocusNode();
amountFocusNode = FocusNode();
// baseFocusNode = FocusNode();
amountController.addListener(_cryptoAmountChanged);
super.initState();
}
@override
void dispose() {
amountController.removeListener(_cryptoAmountChanged);
addressController.dispose();
amountController.dispose();
// baseController.dispose();
addressFocusNode.dispose();
amountFocusNode.dispose();
// baseFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final String locale = ref.watch(
localeServiceChangeNotifierProvider.select(
(value) => value.locale,
),
);
return RoundedWhiteContainer(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
),
child: TextField(
key: const Key("sendViewAddressFieldKey"),
controller: addressController,
readOnly: false,
autocorrect: false,
enableSuggestions: false,
focusNode: addressFocusNode,
style: STextStyles.field(context),
onChanged: (_) {
setState(() {
_addressIsEmpty = addressController.text.isEmpty;
});
},
decoration: standardInputDecoration(
"Enter ${widget.coin.ticker} address",
addressFocusNode,
context,
).copyWith(
contentPadding: const EdgeInsets.only(
left: 16,
top: 6,
bottom: 8,
right: 5,
),
suffixIcon: Padding(
padding: _addressIsEmpty
? const EdgeInsets.only(right: 8)
: const EdgeInsets.only(right: 0),
child: UnconstrainedBox(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
!_addressIsEmpty
? TextFieldIconButton(
semanticsLabel:
"Clear Button. Clears The Address Field Input.",
key: const Key(
"sendViewClearAddressFieldButtonKey"),
onTap: () {
addressController.text = "";
setState(() {
_addressIsEmpty = true;
});
_updateRecipientData();
},
child: const XIcon(),
)
: TextFieldIconButton(
semanticsLabel:
"Paste Button. Pastes From Clipboard To Address Field Input.",
key: const Key(
"sendViewPasteAddressFieldButtonKey"),
onTap: () async {
final ClipboardData? data = await ref
.read(pClipboard)
.getData(Clipboard.kTextPlain);
if (data?.text != null &&
data!.text!.isNotEmpty) {
String content = data.text!.trim();
if (content.contains("\n")) {
content = content.substring(
0, content.indexOf("\n"));
}
addressController.text = content.trim();
setState(() {
_addressIsEmpty =
addressController.text.isEmpty;
});
_updateRecipientData();
}
},
child: _addressIsEmpty
? const ClipboardIcon()
: const XIcon(),
),
if (_addressIsEmpty)
TextFieldIconButton(
semanticsLabel: "Scan QR Button. "
"Opens Camera For Scanning QR Code.",
key: const Key(
"sendViewScanQrButtonKey",
),
onTap: () async {
try {
if (FocusScope.of(context).hasFocus) {
FocusScope.of(context).unfocus();
await Future<void>.delayed(
const Duration(
milliseconds: 75,
),
);
}
final qrResult =
await ref.read(pBarcodeScanner).scan();
Logging.instance.log(
"qrResult content: ${qrResult.rawContent}",
level: LogLevel.Info,
);
/// TODO: deal with address utils
final results =
AddressUtils.parseUri(qrResult.rawContent);
Logging.instance.log(
"qrResult parsed: $results",
level: LogLevel.Info,
);
if (results.isNotEmpty &&
results["scheme"] ==
widget.coin.uriScheme) {
// auto fill address
addressController.text =
(results["address"] ?? "").trim();
// autofill amount field
if (results["amount"] != null) {
final Amount amount =
Decimal.parse(results["amount"]!)
.toAmount(
fractionDigits: widget.coin.decimals,
);
amountController.text = ref
.read(pAmountFormatter(widget.coin))
.format(
amount,
withUnitName: false,
);
}
} else {
addressController.text =
qrResult.rawContent.trim();
}
setState(() {
_addressIsEmpty =
addressController.text.isEmpty;
});
_updateRecipientData();
} on PlatformException catch (e, s) {
Logging.instance.log(
"Failed to get camera permissions while "
"trying to scan qr code in SendView: $e\n$s",
level: LogLevel.Warning,
);
}
},
child: const QrCodeIcon(),
),
],
),
),
),
),
),
),
const SizedBox(
height: 12,
),
TextField(
autocorrect: false,
enableSuggestions: false,
style: STextStyles.smallMed14(context).copyWith(
color: Theme.of(context).extension<StackColors>()!.textDark,
),
key: const Key("amountInputFieldCryptoTextFieldKey"),
controller: amountController,
focusNode: amountFocusNode,
keyboardType: Util.isDesktop
? null
: const TextInputType.numberWithOptions(
signed: false,
decimal: true,
),
textAlign: TextAlign.right,
inputFormatters: [
AmountInputFormatter(
decimals: widget.coin.decimals,
unit: ref.watch(pAmountUnit(widget.coin)),
locale: locale,
),
],
decoration: InputDecoration(
contentPadding: const EdgeInsets.only(
top: 12,
right: 12,
),
hintText: "0",
hintStyle: STextStyles.fieldLabel(context).copyWith(
fontSize: 14,
),
prefixIcon: FittedBox(
fit: BoxFit.scaleDown,
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(
ref
.watch(pAmountUnit(widget.coin))
.unitForCoin(widget.coin),
style: STextStyles.smallMed14(context).copyWith(
color: Theme.of(context)
.extension<StackColors>()!
.accentColorDark),
),
),
),
),
),
// if (ref.watch(prefsChangeNotifierProvider
// .select((value) => value.externalCalls)))
// const SizedBox(
// height: 8,
// ),
// if (ref.watch(prefsChangeNotifierProvider
// .select((value) => value.externalCalls)))
// TextField(
// autocorrect: Util.isDesktop ? false : true,
// enableSuggestions: Util.isDesktop ? false : true,
// style: STextStyles.smallMed14(context).copyWith(
// color: Theme.of(context).extension<StackColors>()!.textDark,
// ),
// key: const Key("amountInputFieldFiatTextFieldKey"),
// controller: baseController,
// focusNode: baseFocusNode,
// keyboardType: Util.isDesktop
// ? null
// : const TextInputType.numberWithOptions(
// signed: false,
// decimal: true,
// ),
// textAlign: TextAlign.right,
// inputFormatters: [
// AmountInputFormatter(
// decimals: 2,
// locale: locale,
// ),
// ],
// onChanged: (baseAmountString) {
// final baseAmount = Amount.tryParseFiatString(
// baseAmountString,
// locale: locale,
// );
// Amount? cryptoAmount;
// final int decimals = widget.coin.decimals;
// if (baseAmount != null) {
// final _price = ref.read(_pPrice(widget.coin));
//
// if (_price == Decimal.zero) {
// cryptoAmount = 0.toAmountAsRaw(
// fractionDigits: decimals,
// );
// } else {
// cryptoAmount = baseAmount <= Amount.zero
// ? 0.toAmountAsRaw(fractionDigits: decimals)
// : (baseAmount.decimal / _price)
// .toDecimal(
// scaleOnInfinitePrecision: decimals,
// )
// .toAmount(fractionDigits: decimals);
// }
// if (ref.read(pRecipient(widget.index))?.amount != null &&
// ref.read(pRecipient(widget.index))?.amount ==
// cryptoAmount) {
// return;
// }
//
// final amountString =
// ref.read(pAmountFormatter(widget.coin)).format(
// cryptoAmount,
// withUnitName: false,
// );
//
// _cryptoAmountChangeLock = true;
// amountController.text = amountString;
// _cryptoAmountChangeLock = false;
// } else {
// cryptoAmount = 0.toAmountAsRaw(
// fractionDigits: decimals,
// );
// _cryptoAmountChangeLock = true;
// amountController.text = "";
// _cryptoAmountChangeLock = false;
// }
//
// _updateRecipientData();
// },
// decoration: InputDecoration(
// contentPadding: const EdgeInsets.only(
// top: 12,
// right: 12,
// ),
// hintText: "0",
// hintStyle: STextStyles.fieldLabel(context).copyWith(
// fontSize: 14,
// ),
// prefixIcon: FittedBox(
// fit: BoxFit.scaleDown,
// child: Padding(
// padding: const EdgeInsets.all(12),
// child: Text(
// ref.watch(prefsChangeNotifierProvider
// .select((value) => value.currency)),
// style: STextStyles.smallMed14(context).copyWith(
// color: Theme.of(context)
// .extension<StackColors>()!
// .accentColorDark),
// ),
// ),
// ),
// ),
// ),
if (widget.remove != null)
const SizedBox(
height: 6,
),
if (widget.remove != null)
Row(
children: [
const Spacer(),
CustomTextButton(
text: "Remove",
onTap: () {
ref.read(pRecipient(widget.index).notifier).state = null;
widget.remove?.call();
},
),
],
),
],
),
);
}
}

View file

@ -29,6 +29,7 @@ import 'package:stackwallet/pages/ordinals/ordinals_view.dart';
import 'package:stackwallet/pages/paynym/paynym_claim_view.dart';
import 'package:stackwallet/pages/paynym/paynym_home_view.dart';
import 'package:stackwallet/pages/receive_view/receive_view.dart';
import 'package:stackwallet/pages/send_view/frost_ms/frost_send_view.dart';
import 'package:stackwallet/pages/send_view/send_view.dart';
import 'package:stackwallet/pages/settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart';
import 'package:stackwallet/pages/settings_views/wallet_settings_view/wallet_settings_view.dart';
@ -63,6 +64,7 @@ import 'package:stackwallet/utilities/logger.dart';
import 'package:stackwallet/utilities/show_loading.dart';
import 'package:stackwallet/utilities/text_styles.dart';
import 'package:stackwallet/wallets/isar/providers/wallet_info_provider.dart';
import 'package:stackwallet/wallets/wallet/impl/bitcoin_frost_wallet.dart';
import 'package:stackwallet/wallets/wallet/impl/firo_wallet.dart';
import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/cash_fusion_interface.dart';
import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart';
@ -973,10 +975,13 @@ class _WalletViewState extends ConsumerState<WalletView> {
// break;
// }
Navigator.of(context).pushNamed(
SendView.routeName,
arguments: Tuple2(
walletId,
coin,
ref.read(pWallets).getWallet(walletId)
is BitcoinFrostWallet
? FrostSendView.routeName
: SendView.routeName,
arguments: (
walletId: walletId,
coin: coin,
),
);
},

View file

@ -10,13 +10,17 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:stackwallet/pages/send_view/frost_ms/frost_import_sign_config_view.dart';
import 'package:stackwallet/pages/send_view/frost_ms/frost_send_view.dart';
import 'package:stackwallet/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list.dart';
import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart';
import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart';
import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart';
import 'package:stackwallet/providers/global/wallets_provider.dart';
import 'package:stackwallet/utilities/enums/coin_enum.dart';
import 'package:stackwallet/wallets/wallet/impl/bitcoin_frost_wallet.dart';
import 'package:stackwallet/widgets/custom_tab_view.dart';
import 'package:stackwallet/widgets/desktop/secondary_button.dart';
import 'package:stackwallet/widgets/rounded_white_container.dart';
class MyWallet extends ConsumerStatefulWidget {
@ -40,11 +44,15 @@ class _MyWalletState extends ConsumerState<MyWallet> {
];
late final bool isEth;
late final Coin coin;
late final bool isFrost;
@override
void initState() {
isEth = ref.read(pWallets).getWallet(widget.walletId).info.coin ==
Coin.ethereum;
final wallet = ref.read(pWallets).getWallet(widget.walletId);
coin = wallet.info.coin;
isFrost = wallet is BitcoinFrostWallet;
isEth = coin == Coin.ethereum;
if (isEth && widget.contractAddress == null) {
titles.add("Transactions");
@ -64,12 +72,37 @@ class _MyWalletState extends ConsumerState<MyWallet> {
titles: titles,
children: [
widget.contractAddress == null
? Padding(
padding: const EdgeInsets.all(20),
child: DesktopSend(
walletId: widget.walletId,
),
)
? isFrost
? Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
SecondaryButton(
width: 200,
buttonHeight: ButtonHeight.l,
label: "Import sign config",
onPressed: () {
Navigator.of(context).pushNamed(
FrostImportSignConfigView.routeName,
arguments: widget.walletId,
);
},
),
],
),
FrostSendView(
walletId: widget.walletId,
coin: coin,
),
],
)
: Padding(
padding: const EdgeInsets.all(20),
child: DesktopSend(
walletId: widget.walletId,
),
)
: Padding(
padding: const EdgeInsets.all(20),
child: DesktopTokenSend(

View file

@ -756,6 +756,24 @@ class RouteGenerator {
}
return _routeError("${settings.name} invalid args: ${args.toString()}");
case FrostSendView.routeName:
if (args is ({
String walletId,
Coin coin,
})) {
return getRoute(
shouldUseMaterialRoute: useMaterialPageRoute,
builder: (_) => FrostSendView(
walletId: args.walletId,
coin: args.coin,
),
settings: RouteSettings(
name: settings.name,
),
);
}
return _routeError("${settings.name} invalid args: ${args.toString()}");
// case MonkeyLoadedView.routeName:
// if (args is Tuple2<String, ChangeNotifierProvider<Manager>>) {
// return getRoute(

View file

@ -29,6 +29,12 @@ import 'package:stackwallet/wallets/models/tx_data.dart';
import 'package:stackwallet/wallets/wallet/wallet.dart';
class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T> {
@override
int get isarTransactionVersion => 2;
@override
bool get supportsMultiRecipient => true;
BitcoinFrostWallet(CryptoCurrencyNetwork network)
: super(BitcoinFrost(network) as T);

View file

@ -55,6 +55,9 @@ abstract class Wallet<T extends CryptoCurrency> {
// default to Transaction class. For TransactionV2 set to 2
int get isarTransactionVersion => 1;
// whether the wallet currently supports multiple recipients per tx
bool get supportsMultiRecipient => false;
Wallet(this.cryptoCurrency);
//============================================================================