coin control select for sending

This commit is contained in:
julian 2023-03-07 15:45:22 -06:00
parent 35c17033d1
commit 6d22304d7b
4 changed files with 448 additions and 328 deletions

View file

@ -15,6 +15,7 @@ import 'package:stackwallet/widgets/background.dart';
import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart';
import 'package:stackwallet/widgets/desktop/primary_button.dart';
import 'package:stackwallet/widgets/desktop/secondary_button.dart';
import 'package:stackwallet/widgets/icon_widgets/x_icon.dart';
import 'package:stackwallet/widgets/rounded_white_container.dart';
import 'package:stackwallet/widgets/toggle.dart';
import 'package:tuple/tuple.dart';
@ -30,6 +31,7 @@ class CoinControlView extends ConsumerStatefulWidget {
required this.walletId,
required this.type,
this.requestedTotal,
this.selectedUTXOs,
}) : super(key: key);
static const routeName = "/coinControl";
@ -37,6 +39,7 @@ class CoinControlView extends ConsumerStatefulWidget {
final String walletId;
final CoinControlViewType type;
final int? requestedTotal;
final Set<UTXO>? selectedUTXOs;
@override
ConsumerState<CoinControlView> createState() => _CoinControlViewState();
@ -48,6 +51,14 @@ class _CoinControlViewState extends ConsumerState<CoinControlView> {
final Set<UTXO> _selectedAvailable = {};
final Set<UTXO> _selectedBlocked = {};
@override
void initState() {
if (widget.selectedUTXOs != null) {
_selectedAvailable.addAll(widget.selectedUTXOs!);
}
super.initState();
}
@override
Widget build(BuildContext context) {
debugPrint("BUILD: $runtimeType");
@ -69,300 +80,326 @@ class _CoinControlViewState extends ConsumerState<CoinControlView> {
.idProperty()
.findAllSync();
return Background(
child: Scaffold(
backgroundColor: Theme.of(context).extension<StackColors>()!.background,
appBar: AppBar(
leading: AppBarBackButton(
onPressed: () {
Navigator.of(context).pop();
},
),
title: Text(
"Coin control",
style: STextStyles.navBarTitle(context),
),
titleSpacing: 0,
),
body: SafeArea(
child: Column(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
return WillPopScope(
onWillPop: () async {
Navigator.of(context).pop(
widget.type == CoinControlViewType.use ? _selectedAvailable : null);
return false;
},
child: Background(
child: Scaffold(
backgroundColor:
Theme.of(context).extension<StackColors>()!.background,
appBar: AppBar(
leading: widget.type == CoinControlViewType.use &&
_selectedAvailable.isNotEmpty
? AppBarIconButton(
icon: XIcon(
width: 24,
height: 24,
color: Theme.of(context)
.extension<StackColors>()!
.topNavIconPrimary,
),
onPressed: () {
setState(() {
_selectedAvailable.clear();
});
},
)
: AppBarBackButton(
onPressed: () {
Navigator.of(context).pop(
widget.type == CoinControlViewType.use
? _selectedAvailable
: null);
},
),
child: Column(
children: [
const SizedBox(
height: 10,
),
RoundedWhiteContainer(
child: Text(
"This option allows you to control, freeze, and utilize "
"outputs at your discretion. Tap the output circle to "
"select.",
style: STextStyles.w500_14(context).copyWith(
color: Theme.of(context)
.extension<StackColors>()!
.textSubtitle1,
),
title: Text(
"Coin control",
style: STextStyles.navBarTitle(context),
),
titleSpacing: 0,
),
body: SafeArea(
child: Column(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
),
child: Column(
children: [
const SizedBox(
height: 10,
),
),
const SizedBox(
height: 10,
),
SizedBox(
height: 48,
child: Toggle(
key: UniqueKey(),
onColor: Theme.of(context)
.extension<StackColors>()!
.popupBG,
onText: "Available outputs",
offColor: Theme.of(context)
.extension<StackColors>()!
.textFieldDefaultBG,
offText: "Frozen outputs",
isOn: _showBlocked,
onValueChanged: (value) {
setState(() {
_showBlocked = value;
});
},
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
RoundedWhiteContainer(
child: Text(
"This option allows you to control, freeze, and utilize "
"outputs at your discretion. Tap the output circle to "
"select.",
style: STextStyles.w500_14(context).copyWith(
color: Theme.of(context)
.extension<StackColors>()!
.textSubtitle1,
),
),
),
),
const SizedBox(
height: 10,
),
Expanded(
child: ListView.separated(
itemCount: ids.length,
separatorBuilder: (context, _) => const SizedBox(
height: 10,
),
itemBuilder: (context, index) {
final utxo = MainDB.instance.isar.utxos
.where()
.idEqualTo(ids[index])
.findFirstSync()!;
return UtxoCard(
key: Key("${utxo.walletId}_${utxo.id}"),
walletId: widget.walletId,
utxo: utxo,
initialSelectedState: _showBlocked
? _selectedBlocked.contains(utxo)
: _selectedAvailable.contains(utxo),
onSelectedChanged: (value) {
if (value) {
_showBlocked
? _selectedBlocked.add(utxo)
: _selectedAvailable.add(utxo);
} else {
_showBlocked
? _selectedBlocked.remove(utxo)
: _selectedAvailable.remove(utxo);
}
setState(() {});
},
onPressed: () async {
final result =
await Navigator.of(context).pushNamed(
UtxoDetailsView.routeName,
arguments: Tuple2(
utxo.id,
widget.walletId,
),
);
if (mounted && result == "refresh") {
setState(() {});
}
},
);
},
const SizedBox(
height: 10,
),
),
],
),
),
),
if (((_showBlocked && _selectedBlocked.isNotEmpty) ||
(!_showBlocked && _selectedAvailable.isNotEmpty)) &&
widget.type == CoinControlViewType.manage)
Container(
decoration: BoxDecoration(
color: Theme.of(context)
.extension<StackColors>()!
.backgroundAppBar,
boxShadow: [
Theme.of(context)
.extension<StackColors>()!
.standardBoxShadow,
],
),
child: Padding(
padding: const EdgeInsets.all(16),
child: SecondaryButton(
label: _showBlocked ? "Unfreeze" : "Freeze",
onPressed: () async {
if (_showBlocked) {
await MainDB.instance.putUTXOs(_selectedBlocked
.map(
(e) => e.copyWith(
isBlocked: false,
),
)
.toList());
_selectedBlocked.clear();
} else {
await MainDB.instance.putUTXOs(_selectedAvailable
.map(
(e) => e.copyWith(
isBlocked: true,
),
)
.toList());
_selectedAvailable.clear();
}
setState(() {});
},
),
),
),
if (!_showBlocked && widget.type == CoinControlViewType.use)
Container(
decoration: BoxDecoration(
color: Theme.of(context)
.extension<StackColors>()!
.backgroundAppBar,
boxShadow: [
Theme.of(context)
.extension<StackColors>()!
.standardBoxShadow,
],
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
RoundedWhiteContainer(
child: Column(
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
"Selected amount",
style: STextStyles.w600_14(context),
),
Builder(builder: (context) {
int selectedSum = _selectedAvailable
.map((e) => e.value)
.reduce(
(value, element) => value += element,
);
return Text(
"${Format.satoshisToAmount(
selectedSum,
coin: coin,
).toStringAsFixed(
coin.decimals,
)} ${coin.ticker}",
style: widget.requestedTotal == null
? STextStyles.w600_14(context)
: STextStyles.w600_14(context)
.copyWith(
color: selectedSum >=
widget.requestedTotal!
? Theme.of(context)
.extension<
StackColors>()!
.accentColorGreen
: Theme.of(context)
.extension<
StackColors>()!
.accentColorRed),
);
}),
],
SizedBox(
height: 48,
child: Toggle(
key: UniqueKey(),
onColor: Theme.of(context)
.extension<StackColors>()!
.popupBG,
onText: "Available outputs",
offColor: Theme.of(context)
.extension<StackColors>()!
.textFieldDefaultBG,
offText: "Frozen outputs",
isOn: _showBlocked,
onValueChanged: (value) {
setState(() {
_showBlocked = value;
});
},
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
),
if (widget.requestedTotal != null)
Padding(
padding: const EdgeInsets.symmetric(
vertical: 12,
),
child: Container(
width: double.infinity,
height: 2,
color: Theme.of(context)
.extension<StackColors>()!
.backgroundAppBar,
),
),
if (widget.requestedTotal != null)
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
"Amount to send",
style: STextStyles.w600_14(context),
),
Text(
"${Format.satoshisToAmount(
widget.requestedTotal!,
coin: coin,
).toStringAsFixed(
coin.decimals,
)} ${coin.ticker}",
style: STextStyles.w600_14(context),
),
],
),
],
),
),
),
const SizedBox(
height: 12,
height: 10,
),
PrimaryButton(
label: "Use coins",
onPressed: () async {
if (_showBlocked) {
await MainDB.instance.putUTXOs(_selectedBlocked
.map(
(e) => e.copyWith(
isBlocked: false,
Expanded(
child: ListView.separated(
itemCount: ids.length,
separatorBuilder: (context, _) => const SizedBox(
height: 10,
),
itemBuilder: (context, index) {
final utxo = MainDB.instance.isar.utxos
.where()
.idEqualTo(ids[index])
.findFirstSync()!;
final isSelected = _showBlocked
? _selectedBlocked.contains(utxo)
: _selectedAvailable.contains(utxo);
return UtxoCard(
key: Key(
"${utxo.walletId}_${utxo.id}_$isSelected"),
walletId: widget.walletId,
utxo: utxo,
canSelect: widget.type ==
CoinControlViewType.manage ||
(widget.type == CoinControlViewType.use &&
!_showBlocked),
initialSelectedState: isSelected,
onSelectedChanged: (value) {
if (value) {
_showBlocked
? _selectedBlocked.add(utxo)
: _selectedAvailable.add(utxo);
} else {
_showBlocked
? _selectedBlocked.remove(utxo)
: _selectedAvailable.remove(utxo);
}
setState(() {});
},
onPressed: () async {
final result =
await Navigator.of(context).pushNamed(
UtxoDetailsView.routeName,
arguments: Tuple2(
utxo.id,
widget.walletId,
),
)
.toList());
_selectedBlocked.clear();
} else {
await MainDB.instance.putUTXOs(_selectedAvailable
.map(
(e) => e.copyWith(
isBlocked: true,
),
)
.toList());
_selectedAvailable.clear();
}
setState(() {});
},
);
if (mounted && result == "refresh") {
setState(() {});
}
},
);
},
),
),
],
),
),
),
],
if (((_showBlocked && _selectedBlocked.isNotEmpty) ||
(!_showBlocked && _selectedAvailable.isNotEmpty)) &&
widget.type == CoinControlViewType.manage)
Container(
decoration: BoxDecoration(
color: Theme.of(context)
.extension<StackColors>()!
.backgroundAppBar,
boxShadow: [
Theme.of(context)
.extension<StackColors>()!
.standardBoxShadow,
],
),
child: Padding(
padding: const EdgeInsets.all(16),
child: SecondaryButton(
label: _showBlocked ? "Unfreeze" : "Freeze",
onPressed: () async {
if (_showBlocked) {
await MainDB.instance.putUTXOs(_selectedBlocked
.map(
(e) => e.copyWith(
isBlocked: false,
),
)
.toList());
_selectedBlocked.clear();
} else {
await MainDB.instance.putUTXOs(_selectedAvailable
.map(
(e) => e.copyWith(
isBlocked: true,
),
)
.toList());
_selectedAvailable.clear();
}
setState(() {});
},
),
),
),
if (!_showBlocked && widget.type == CoinControlViewType.use)
Container(
decoration: BoxDecoration(
color: Theme.of(context)
.extension<StackColors>()!
.backgroundAppBar,
boxShadow: [
Theme.of(context)
.extension<StackColors>()!
.standardBoxShadow,
],
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
RoundedWhiteContainer(
padding: const EdgeInsets.all(0),
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
"Selected amount",
style: STextStyles.w600_14(context),
),
Builder(
builder: (context) {
int selectedSum =
_selectedAvailable.isEmpty
? 0
: _selectedAvailable
.map((e) => e.value)
.reduce(
(value, element) =>
value += element,
);
return Text(
"${Format.satoshisToAmount(
selectedSum,
coin: coin,
).toStringAsFixed(
coin.decimals,
)} ${coin.ticker}",
style: widget.requestedTotal == null
? STextStyles.w600_14(context)
: STextStyles.w600_14(context).copyWith(
color: selectedSum >=
widget
.requestedTotal!
? Theme.of(context)
.extension<
StackColors>()!
.accentColorGreen
: Theme.of(context)
.extension<
StackColors>()!
.accentColorRed),
);
},
),
],
),
),
if (widget.requestedTotal != null)
Container(
width: double.infinity,
height: 1.5,
color: Theme.of(context)
.extension<StackColors>()!
.backgroundAppBar,
),
if (widget.requestedTotal != null)
Padding(
padding: const EdgeInsets.all(12),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
"Amount to send",
style: STextStyles.w600_14(context),
),
Text(
"${Format.satoshisToAmount(
widget.requestedTotal!,
coin: coin,
).toStringAsFixed(
coin.decimals,
)} ${coin.ticker}",
style: STextStyles.w600_14(context),
),
],
),
),
],
),
),
const SizedBox(
height: 12,
),
PrimaryButton(
label: "Use coins",
enabled: _selectedAvailable.isNotEmpty,
onPressed: () async {
Navigator.of(context).pop(
_selectedAvailable,
);
},
),
],
),
),
),
],
),
),
),
),

View file

@ -21,6 +21,7 @@ class UtxoCard extends ConsumerStatefulWidget {
required this.walletId,
required this.onSelectedChanged,
required this.initialSelectedState,
required this.canSelect,
this.onPressed,
}) : super(key: key);
@ -29,6 +30,7 @@ class UtxoCard extends ConsumerStatefulWidget {
final void Function(bool) onSelectedChanged;
final bool initialSelectedState;
final VoidCallback? onPressed;
final bool canSelect;
@override
ConsumerState<UtxoCard> createState() => _UtxoCardState();
@ -90,12 +92,16 @@ class _UtxoCardState extends ConsumerState<UtxoCard> {
: Colors.transparent,
child: Row(
children: [
GestureDetector(
onTap: () {
_selected = !_selected;
widget.onSelectedChanged(_selected);
setState(() {});
},
ConditionalParent(
condition: widget.canSelect,
builder: (child) => GestureDetector(
onTap: () {
_selected = !_selected;
widget.onSelectedChanged(_selected);
setState(() {});
},
child: child,
),
child: SvgPicture.asset(
_selected
? Assets.svg.coinControl.selected

View file

@ -7,9 +7,11 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.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/models/paynym/paynym_account_lite.dart';
import 'package:stackwallet/models/send_view_auto_fill_data.dart';
import 'package:stackwallet/pages/address_book_views/address_book_view.dart';
import 'package:stackwallet/pages/coin_control/coin_control_view.dart';
import 'package:stackwallet/pages/send_view/confirm_transaction_view.dart';
import 'package:stackwallet/pages/send_view/sub_widgets/building_transaction_dialog.dart';
import 'package:stackwallet/pages/send_view/sub_widgets/firo_balance_selection_sheet.dart';
@ -43,9 +45,11 @@ import 'package:stackwallet/widgets/icon_widgets/addressbook_icon.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_dialog.dart';
import 'package:stackwallet/widgets/stack_text_field.dart';
import 'package:stackwallet/widgets/textfield_icon_button.dart';
import 'package:tuple/tuple.dart';
class SendView extends ConsumerStatefulWidget {
const SendView({
@ -104,6 +108,8 @@ class _SendViewState extends ConsumerState<SendView> {
Decimal? _cachedBalance;
Set<UTXO> selectedUTXOs = {};
void _cryptoAmountChanged() async {
if (!_cryptoAmountChangeLock) {
final String cryptoAmount = cryptoAmountController.text;
@ -313,51 +319,54 @@ class _SendViewState extends ConsumerState<SendView> {
Format.decimalAmountToSatoshis(manager.balance.getSpendable(), coin);
}
// confirm send all
if (amount == availableBalance) {
final bool? shouldSendAll = await showDialog<bool>(
context: context,
useSafeArea: false,
barrierDismissible: true,
builder: (context) {
return StackDialog(
title: "Confirm send all",
message:
"You are about to send your entire balance. Would you like to continue?",
leftButton: TextButton(
style: Theme.of(context)
.extension<StackColors>()!
.getSecondaryEnabledButtonStyle(context),
child: Text(
"Cancel",
style: STextStyles.button(context).copyWith(
color: Theme.of(context)
.extension<StackColors>()!
.accentColorDark),
if (!manager.hasCoinControlSupport ||
(manager.hasCoinControlSupport && selectedUTXOs.isEmpty)) {
// confirm send all
if (amount == availableBalance) {
final bool? shouldSendAll = await showDialog<bool>(
context: context,
useSafeArea: false,
barrierDismissible: true,
builder: (context) {
return StackDialog(
title: "Confirm send all",
message:
"You are about to send your entire balance. Would you like to continue?",
leftButton: TextButton(
style: Theme.of(context)
.extension<StackColors>()!
.getSecondaryEnabledButtonStyle(context),
child: Text(
"Cancel",
style: STextStyles.button(context).copyWith(
color: Theme.of(context)
.extension<StackColors>()!
.accentColorDark),
),
onPressed: () {
Navigator.of(context).pop(false);
},
),
onPressed: () {
Navigator.of(context).pop(false);
},
),
rightButton: TextButton(
style: Theme.of(context)
.extension<StackColors>()!
.getPrimaryEnabledButtonStyle(context),
child: Text(
"Yes",
style: STextStyles.button(context),
rightButton: TextButton(
style: Theme.of(context)
.extension<StackColors>()!
.getPrimaryEnabledButtonStyle(context),
child: Text(
"Yes",
style: STextStyles.button(context),
),
onPressed: () {
Navigator.of(context).pop(true);
},
),
onPressed: () {
Navigator.of(context).pop(true);
},
),
);
},
);
);
},
);
if (shouldSendAll == null || shouldSendAll == false) {
// cancel preview
return;
if (shouldSendAll == null || shouldSendAll == false) {
// cancel preview
return;
}
}
}
@ -393,7 +402,12 @@ class _SendViewState extends ConsumerState<SendView> {
txData = await wallet.preparePaymentCodeSend(
paymentCode: paymentCode,
satoshiAmount: amount,
args: {"feeRate": feeRate},
args: {
"feeRate": feeRate,
"UTXOs": (manager.hasCoinControlSupport && selectedUTXOs.isNotEmpty)
? selectedUTXOs
: null,
},
);
} else if ((coin == Coin.firo || coin == Coin.firoTestNet) &&
ref.read(publicPrivateBalanceStateProvider.state).state !=
@ -407,7 +421,12 @@ class _SendViewState extends ConsumerState<SendView> {
txData = await manager.prepareSend(
address: _address!,
satoshiAmount: amount,
args: {"feeRate": ref.read(feeRateTypeStateProvider)},
args: {
"feeRate": ref.read(feeRateTypeStateProvider),
"UTXOs": (manager.hasCoinControlSupport && selectedUTXOs.isNotEmpty)
? selectedUTXOs
: null,
},
);
}
@ -565,6 +584,12 @@ class _SendViewState extends ConsumerState<SendView> {
final String locale = ref.watch(
localeServiceChangeNotifierProvider.select((value) => value.locale));
final showCoinControl = ref.watch(
walletsChangeNotifierProvider.select(
(value) => value.getManager(walletId).hasCoinControlSupport,
),
);
if (coin == Coin.firo || coin == Coin.firoTestNet) {
ref.listen(publicPrivateBalanceStateProvider, (previous, next) {
if (_amountToSend == null) {
@ -1484,6 +1509,56 @@ class _SendViewState extends ConsumerState<SendView> {
),
),
),
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 {
final result =
await Navigator.of(context).pushNamed(
CoinControlView.routeName,
arguments: Tuple4(
walletId,
CoinControlViewType.use,
_amountToSend != null
? Format.decimalAmountToSatoshis(
_amountToSend!,
coin,
)
: null,
selectedUTXOs,
),
);
if (result is Set<UTXO>) {
setState(() {
selectedUTXOs = result;
});
}
},
),
],
),
),
const SizedBox(
height: 12,
),

View file

@ -229,13 +229,15 @@ class RouteGenerator {
name: settings.name,
),
);
} else if (args is Tuple3<String, CoinControlViewType, int?>) {
} else if (args
is Tuple4<String, CoinControlViewType, int?, Set<UTXO>?>) {
return getRoute(
shouldUseMaterialRoute: useMaterialPageRoute,
builder: (_) => CoinControlView(
walletId: args.item1,
type: args.item2,
requestedTotal: args.item3,
selectedUTXOs: args.item4,
),
settings: RouteSettings(
name: settings.name,