From 6f7f9c24eb3e94bc0eded112fa042e6069ad539c Mon Sep 17 00:00:00 2001 From: julian Date: Thu, 16 Mar 2023 12:10:59 -0600 Subject: [PATCH] desktop coin control --- lib/db/queries/queries.dart | 81 ++++ .../send_view/confirm_transaction_view.dart | 2 + .../desktop_coin_control_use_dialog.dart | 413 +++++++++++++----- .../desktop_coin_control_view.dart | 188 ++++++-- .../coin_control/utxo_row.dart | 34 +- .../wallet_view/sub_widgets/desktop_send.dart | 209 +++++---- .../custom_buttons/dropdown_button.dart | 119 +++++ lib/widgets/expandable2.dart | 182 ++++++++ 8 files changed, 990 insertions(+), 238 deletions(-) create mode 100644 lib/widgets/expandable2.dart diff --git a/lib/db/queries/queries.dart b/lib/db/queries/queries.dart index 907264bc6..2995de06d 100644 --- a/lib/db/queries/queries.dart +++ b/lib/db/queries/queries.dart @@ -98,4 +98,85 @@ extension MainDBQueries on MainDB { } return ids; } + + Map> queryUTXOsGroupedByAddressSync({ + required String walletId, + required CCFilter filter, + required CCSortDescriptor sort, + required String searchTerm, + required Coin coin, + }) { + var preSort = getUTXOs(walletId).filter().group((q) { + final qq = q.group( + (q) => q.usedIsNull().or().usedEqualTo(false), + ); + switch (filter) { + case CCFilter.frozen: + return qq.and().isBlockedEqualTo(true); + case CCFilter.available: + return qq.and().isBlockedEqualTo(false); + case CCFilter.all: + return qq; + } + }); + + if (searchTerm.isNotEmpty) { + preSort = preSort.and().group( + (q) { + var qq = q.addressContains(searchTerm, caseSensitive: false); + + qq = qq.or().nameContains(searchTerm, caseSensitive: false); + qq = qq.or().group( + (q) => q + .isBlockedEqualTo(true) + .and() + .blockedReasonContains(searchTerm, caseSensitive: false), + ); + + qq = qq.or().txidContains(searchTerm, caseSensitive: false); + qq = qq.or().blockHashContains(searchTerm, caseSensitive: false); + + final maybeDecimal = Decimal.tryParse(searchTerm); + if (maybeDecimal != null) { + qq = qq.or().valueEqualTo( + Format.decimalAmountToSatoshis( + maybeDecimal, + coin, + ), + ); + } + + final maybeInt = int.tryParse(searchTerm); + if (maybeInt != null) { + qq = qq.or().valueEqualTo(maybeInt); + } + + return qq; + }, + ); + } + + final List utxos; + switch (sort) { + case CCSortDescriptor.age: + utxos = preSort.sortByBlockHeight().findAllSync(); + break; + case CCSortDescriptor.address: + utxos = preSort.sortByAddress().findAllSync(); + break; + case CCSortDescriptor.value: + utxos = preSort.sortByValueDesc().findAllSync(); + break; + } + + final Map> results = {}; + for (final utxo in utxos) { + if (results[utxo.address!] == null) { + results[utxo.address!] = []; + } + results[utxo.address!]!.add(utxo.id); + } + + return results; + } } diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index 83c211e02..ac7e41592 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -9,6 +9,7 @@ import 'package:stackwallet/notifications/show_flush_bar.dart'; import 'package:stackwallet/pages/pinpad_views/lock_screen_view.dart'; import 'package:stackwallet/pages/send_view/sub_widgets/sending_transaction_dialog.dart'; import 'package:stackwallet/pages/wallet_view/wallet_view.dart'; +import 'package:stackwallet/pages_desktop_specific/coin_control/desktop_coin_control_use_dialog.dart'; import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart'; import 'package:stackwallet/providers/providers.dart'; import 'package:stackwallet/providers/wallet/public_private_balance_state_provider.dart'; @@ -124,6 +125,7 @@ class _ConfirmTransactionViewState ]); txid = results.first as String; + ref.refresh(desktopUseUTXOs); // save note await ref diff --git a/lib/pages_desktop_specific/coin_control/desktop_coin_control_use_dialog.dart b/lib/pages_desktop_specific/coin_control/desktop_coin_control_use_dialog.dart index 3b2cb4b07..10a495d1d 100644 --- a/lib/pages_desktop_specific/coin_control/desktop_coin_control_use_dialog.dart +++ b/lib/pages_desktop_specific/coin_control/desktop_coin_control_use_dialog.dart @@ -1,3 +1,4 @@ +import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; @@ -9,27 +10,34 @@ import 'package:stackwallet/providers/global/wallets_provider.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/format.dart'; import 'package:stackwallet/utilities/text_styles.dart'; import 'package:stackwallet/utilities/theme/stack_colors.dart'; +import 'package:stackwallet/widgets/animated_widgets/rotate_icon.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/dropdown_button.dart'; import 'package:stackwallet/widgets/desktop/desktop_dialog.dart'; import 'package:stackwallet/widgets/desktop/primary_button.dart'; import 'package:stackwallet/widgets/desktop/secondary_button.dart'; +import 'package:stackwallet/widgets/expandable2.dart'; import 'package:stackwallet/widgets/icon_widgets/x_icon.dart'; import 'package:stackwallet/widgets/rounded_container.dart'; import 'package:stackwallet/widgets/stack_text_field.dart'; import 'package:stackwallet/widgets/textfield_icon_button.dart'; import 'package:stackwallet/widgets/toggle.dart'; -final desktopUseUTXOs = StateProvider.autoDispose((ref) => {}); +final desktopUseUTXOs = StateProvider((ref) => {}); class DesktopCoinControlUseDialog extends ConsumerStatefulWidget { const DesktopCoinControlUseDialog({ Key? key, required this.walletId, + this.amountToSend, }) : super(key: key); final String walletId; + final Decimal? amountToSend; @override ConsumerState createState() => @@ -42,13 +50,22 @@ class _DesktopCoinControlUseDialogState late final Coin coin; final searchFieldFocusNode = FocusNode(); - final Set _selectedUTXOs = {}; + final Set _selectedUTXOsData = {}; + final Set _selectedUTXOs = {}; + + Map>? _map; + List? _list; String _searchString = ""; CCFilter _filter = CCFilter.available; CCSortDescriptor _sort = CCSortDescriptor.age; + bool selectedChanged(Set newSelected) { + if (ref.read(desktopUseUTXOs).length != newSelected.length) return true; + return !ref.read(desktopUseUTXOs).containsAll(newSelected); + } + @override void initState() { _searchController = TextEditingController(); @@ -56,6 +73,13 @@ class _DesktopCoinControlUseDialogState .read(walletsChangeNotifierProvider) .getManager(widget.walletId) .coin; + + for (final utxo in ref.read(desktopUseUTXOs)) { + final data = UtxoRowData(utxo.id, true); + _selectedUTXOs.add(utxo); + _selectedUTXOsData.add(data); + } + super.initState(); } @@ -68,14 +92,40 @@ class _DesktopCoinControlUseDialogState @override Widget build(BuildContext context) { - final ids = MainDB.instance.queryUTXOsSync( - walletId: widget.walletId, - filter: _filter, - sort: _sort, - searchTerm: _searchString, + debugPrint("BUILD: $runtimeType"); + + if (_sort == CCSortDescriptor.address) { + _list = null; + _map = MainDB.instance.queryUTXOsGroupedByAddressSync( + walletId: widget.walletId, + filter: _filter, + sort: _sort, + searchTerm: _searchString, + coin: coin, + ); + } else { + _map = null; + _list = MainDB.instance.queryUTXOsSync( + walletId: widget.walletId, + filter: _filter, + sort: _sort, + searchTerm: _searchString, + coin: coin, + ); + } + + final selectedSum = Format.satoshisToAmount( + _selectedUTXOs + .map((e) => e.value) + .fold(0, (value, element) => value += element), coin: coin, ); + final enableApply = widget.amountToSend == null + ? selectedChanged(_selectedUTXOs) + : selectedChanged(_selectedUTXOs) && + widget.amountToSend! <= selectedSum; + return DesktopDialog( maxWidth: 700, maxHeight: MediaQuery.of(context).size.height - 128, @@ -220,75 +270,165 @@ class _DesktopCoinControlUseDialogState const SizedBox( width: 16, ), - SizedBox( - height: 56, - width: 56, - child: TextButton( - style: Theme.of(context) - .extension()! - .getSecondaryEnabledButtonStyle(context) - ?.copyWith( - shape: MaterialStateProperty.all( - RoundedRectangleBorder( - side: BorderSide( - color: Theme.of(context) - .extension()! - .buttonBackBorderSecondary, - width: 1, - ), - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), - ), - ), - onPressed: () {}, - child: SvgPicture.asset( - Assets.svg.list, - width: 20, - height: 20, - ), - ), - ), + JDropdownIconButton( + redrawOnScreenSizeChanged: true, + groupValue: _sort, + items: CCSortDescriptor.values.toSet(), + onSelectionChanged: (CCSortDescriptor? newValue) { + if (newValue != null && newValue != _sort) { + setState(() { + _sort = newValue; + }); + } + }, + ) ], ), const SizedBox( height: 16, ), Expanded( - child: ListView.separated( - shrinkWrap: true, - primary: false, - itemCount: ids.length, - separatorBuilder: (context, _) => const SizedBox( - height: 10, - ), - itemBuilder: (context, index) { - final utxo = MainDB.instance.isar.utxos - .where() - .idEqualTo(ids[index]) - .findFirstSync()!; - final data = UtxoRowData(utxo.id, false); - data.selected = _selectedUTXOs.contains(data); + child: _list != null + ? ListView.separated( + shrinkWrap: true, + primary: false, + itemCount: _list!.length, + separatorBuilder: (context, _) => const SizedBox( + height: 10, + ), + itemBuilder: (context, index) { + final utxo = MainDB.instance.isar.utxos + .where() + .idEqualTo(_list![index]) + .findFirstSync()!; + final data = UtxoRowData(utxo.id, false); + data.selected = _selectedUTXOsData.contains(data); - return UtxoRow( - key: Key( - "${utxo.walletId}_${utxo.id}_${utxo.isBlocked}"), - data: data, - compact: true, - walletId: widget.walletId, - onSelectionChanged: (value) { - setState(() { - if (data.selected) { - _selectedUTXOs.add(value); - } else { - _selectedUTXOs.remove(value); - } - }); - }, - ); - }, - ), + return UtxoRow( + key: Key( + "${utxo.walletId}_${utxo.id}_${utxo.isBlocked}"), + data: data, + compact: true, + walletId: widget.walletId, + onSelectionChanged: (value) { + setState(() { + if (data.selected) { + _selectedUTXOsData.add(value); + _selectedUTXOs.add(utxo); + } else { + _selectedUTXOsData.remove(value); + _selectedUTXOs.remove(utxo); + } + }); + }, + ); + }, + ) + : ListView.separated( + itemCount: _map!.entries.length, + separatorBuilder: (context, _) => const SizedBox( + height: 10, + ), + itemBuilder: (context, index) { + final entry = _map!.entries.elementAt(index); + final _controller = RotateIconController(); + + return Expandable2( + border: Theme.of(context) + .extension()! + .backgroundAppBar, + background: Theme.of(context) + .extension()! + .popupBG, + animationDurationMultiplier: + 0.2 * entry.value.length, + onExpandWillChange: (state) { + if (state == Expandable2State.expanded) { + _controller.forward?.call(); + } else { + _controller.reverse?.call(); + } + }, + header: RoundedContainer( + padding: const EdgeInsets.all(20), + color: Colors.transparent, + child: Row( + children: [ + SvgPicture.asset( + Assets.svg.iconFor(coin: coin), + width: 24, + height: 24, + ), + const SizedBox( + width: 12, + ), + Expanded( + flex: 3, + child: Text( + entry.key, + style: STextStyles.w600_14(context), + ), + ), + Expanded( + child: Text( + "${entry.value.length} " + "output${entry.value.length > 1 ? "s" : ""}", + style: STextStyles + .desktopTextExtraExtraSmall( + context), + ), + ), + RotateIcon( + animationDurationMultiplier: + 0.2 * entry.value.length, + icon: SvgPicture.asset( + Assets.svg.chevronDown, + width: 14, + color: Theme.of(context) + .extension()! + .textSubtitle1, + ), + curve: Curves.easeInOut, + controller: _controller, + ), + ], + ), + ), + children: entry.value.map( + (id) { + final utxo = MainDB.instance.isar.utxos + .where() + .idEqualTo(id) + .findFirstSync()!; + final data = UtxoRowData(utxo.id, false); + data.selected = + _selectedUTXOsData.contains(data); + + return UtxoRow( + key: Key( + "${utxo.walletId}_${utxo.id}_${utxo.isBlocked}"), + data: data, + compact: true, + compactWithBorder: false, + raiseOnSelected: false, + walletId: widget.walletId, + onSelectionChanged: (value) { + setState(() { + if (data.selected) { + _selectedUTXOsData.add(value); + _selectedUTXOs.add(utxo); + } else { + _selectedUTXOsData.remove(value); + _selectedUTXOs.remove(utxo); + } + }); + }, + ); + }, + ).toList(), + ); + }, + ), ), const SizedBox( height: 16, @@ -297,29 +437,95 @@ class _DesktopCoinControlUseDialogState color: Theme.of(context) .extension()! .textFieldDefaultBG, - padding: const EdgeInsets.all(16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "Selected amount", - style: STextStyles.desktopTextExtraExtraSmall(context) - .copyWith( - color: Theme.of(context) - .extension()! - .textDark, - ), + padding: EdgeInsets.zero, + child: ConditionalParent( + condition: widget.amountToSend != null, + builder: (child) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + child, + Container( + height: 1.2, + color: Theme.of(context) + .extension()! + .popupBG, + ), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + "Amount to send", + style: + STextStyles.desktopTextExtraExtraSmall( + context) + .copyWith( + color: Theme.of(context) + .extension()! + .textDark, + ), + ), + Text( + "${widget.amountToSend!.toStringAsFixed( + coin.decimals, + )}" + " ${coin.ticker}", + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of(context) + .extension()! + .textDark, + ), + ), + ], + ), + ), + ], + ); + }, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Selected amount", + style: STextStyles.desktopTextExtraExtraSmall( + context) + .copyWith( + color: Theme.of(context) + .extension()! + .textDark, + ), + ), + Text( + "${selectedSum.toStringAsFixed( + coin.decimals, + )} ${coin.ticker}", + style: STextStyles.desktopTextExtraExtraSmall( + context) + .copyWith( + color: widget.amountToSend == null + ? Theme.of(context) + .extension()! + .textDark + : selectedSum < widget.amountToSend! + ? Theme.of(context) + .extension()! + .accentColorRed + : Theme.of(context) + .extension()! + .accentColorGreen, + ), + ), + ], ), - Text( - "LOL", - style: STextStyles.desktopTextExtraExtraSmall(context) - .copyWith( - color: Theme.of(context) - .extension()! - .textDark, - ), - ), - ], + ), ), ), const SizedBox( @@ -329,14 +535,17 @@ class _DesktopCoinControlUseDialogState children: [ Expanded( child: SecondaryButton( - enabled: _selectedUTXOs.isNotEmpty, + enabled: _selectedUTXOsData.isNotEmpty, buttonHeight: ButtonHeight.l, - label: _selectedUTXOs.isEmpty + label: _selectedUTXOsData.isEmpty ? "Clear selection" - : "Clear selection (${_selectedUTXOs.length})", - onPressed: () => setState(() { - _selectedUTXOs.clear(); - }), + : "Clear selection (${_selectedUTXOsData.length})", + onPressed: () { + setState(() { + _selectedUTXOsData.clear(); + _selectedUTXOs.clear(); + }); + }, ), ), const SizedBox( @@ -344,9 +553,15 @@ class _DesktopCoinControlUseDialogState ), Expanded( child: PrimaryButton( - enabled: _selectedUTXOs.isNotEmpty, + enabled: enableApply, buttonHeight: ButtonHeight.l, - label: "Use coins", + label: "Apply", + onPressed: () { + ref.read(desktopUseUTXOs.state).state = + _selectedUTXOs; + + Navigator.of(context, rootNavigator: true).pop(); + }, ), ), ], diff --git a/lib/pages_desktop_specific/coin_control/desktop_coin_control_view.dart b/lib/pages_desktop_specific/coin_control/desktop_coin_control_view.dart index 8b1e255c1..cf27cc80e 100644 --- a/lib/pages_desktop_specific/coin_control/desktop_coin_control_view.dart +++ b/lib/pages_desktop_specific/coin_control/desktop_coin_control_view.dart @@ -12,12 +12,15 @@ import 'package:stackwallet/utilities/constants.dart'; import 'package:stackwallet/utilities/enums/coin_enum.dart'; import 'package:stackwallet/utilities/text_styles.dart'; import 'package:stackwallet/utilities/theme/stack_colors.dart'; +import 'package:stackwallet/widgets/animated_widgets/rotate_icon.dart'; import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; import 'package:stackwallet/widgets/custom_buttons/dropdown_button.dart'; import 'package:stackwallet/widgets/desktop/desktop_app_bar.dart'; import 'package:stackwallet/widgets/desktop/desktop_scaffold.dart'; import 'package:stackwallet/widgets/desktop/secondary_button.dart'; +import 'package:stackwallet/widgets/expandable2.dart'; import 'package:stackwallet/widgets/icon_widgets/x_icon.dart'; +import 'package:stackwallet/widgets/rounded_container.dart'; import 'package:stackwallet/widgets/stack_text_field.dart'; import 'package:stackwallet/widgets/textfield_icon_button.dart'; @@ -44,6 +47,9 @@ class _DesktopCoinControlViewState final Set _selectedUTXOs = {}; + Map>? _map; + List? _list; + String _searchString = ""; CCFilter _filter = CCFilter.all; @@ -70,13 +76,25 @@ class _DesktopCoinControlViewState Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); - final ids = MainDB.instance.queryUTXOsSync( - walletId: widget.walletId, - filter: _filter, - sort: _sort, - searchTerm: _searchString, - coin: coin, - ); + if (_sort == CCSortDescriptor.address) { + _list = null; + _map = MainDB.instance.queryUTXOsGroupedByAddressSync( + walletId: widget.walletId, + filter: _filter, + sort: _sort, + searchTerm: _searchString, + coin: coin, + ); + } else { + _map = null; + _list = MainDB.instance.queryUTXOsSync( + walletId: widget.walletId, + filter: _filter, + sort: _sort, + searchTerm: _searchString, + coin: coin, + ); + } return DesktopScaffold( appBar: DesktopAppBar( @@ -264,35 +282,135 @@ class _DesktopCoinControlViewState padding: const EdgeInsets.symmetric( horizontal: 24, ), - 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 data = UtxoRowData(utxo.id, false); - data.selected = _selectedUTXOs.contains(data); + child: _list != null + ? ListView.separated( + itemCount: _list!.length, + separatorBuilder: (context, _) => const SizedBox( + height: 10, + ), + itemBuilder: (context, index) { + final utxo = MainDB.instance.isar.utxos + .where() + .idEqualTo(_list![index]) + .findFirstSync()!; + final data = UtxoRowData(utxo.id, false); + data.selected = _selectedUTXOs.contains(data); - return UtxoRow( - key: Key("${utxo.walletId}_${utxo.id}_${utxo.isBlocked}"), - data: data, - walletId: widget.walletId, - onSelectionChanged: (value) { - setState(() { - if (data.selected) { - _selectedUTXOs.add(value); - } else { - _selectedUTXOs.remove(value); - } - }); - }, - ); - }, - ), + return UtxoRow( + key: Key( + "${utxo.walletId}_${utxo.id}_${utxo.isBlocked}"), + data: data, + walletId: widget.walletId, + onSelectionChanged: (value) { + setState(() { + if (data.selected) { + _selectedUTXOs.add(value); + } else { + _selectedUTXOs.remove(value); + } + }); + }, + ); + }, + ) + : ListView.separated( + itemCount: _map!.entries.length, + separatorBuilder: (context, _) => const SizedBox( + height: 10, + ), + itemBuilder: (context, index) { + final entry = _map!.entries.elementAt(index); + final _controller = RotateIconController(); + + return Expandable2( + border: Theme.of(context) + .extension()! + .backgroundAppBar, + background: Theme.of(context) + .extension()! + .popupBG, + animationDurationMultiplier: 0.2 * entry.value.length, + onExpandWillChange: (state) { + if (state == Expandable2State.expanded) { + _controller.forward?.call(); + } else { + _controller.reverse?.call(); + } + }, + header: RoundedContainer( + padding: const EdgeInsets.all(20), + color: Colors.transparent, + child: Row( + children: [ + SvgPicture.asset( + Assets.svg.iconFor(coin: coin), + width: 24, + height: 24, + ), + const SizedBox( + width: 12, + ), + Expanded( + child: Text( + entry.key, + style: STextStyles.w600_14(context), + ), + ), + Expanded( + child: Text( + "${entry.value.length} " + "output${entry.value.length > 1 ? "s" : ""}", + style: + STextStyles.desktopTextExtraExtraSmall( + context), + ), + ), + RotateIcon( + animationDurationMultiplier: + 0.2 * entry.value.length, + icon: SvgPicture.asset( + Assets.svg.chevronDown, + width: 14, + color: Theme.of(context) + .extension()! + .textSubtitle1, + ), + curve: Curves.easeInOut, + controller: _controller, + ), + ], + ), + ), + children: entry.value.map( + (id) { + final utxo = MainDB.instance.isar.utxos + .where() + .idEqualTo(id) + .findFirstSync()!; + final data = UtxoRowData(utxo.id, false); + data.selected = _selectedUTXOs.contains(data); + + return UtxoRow( + key: Key( + "${utxo.walletId}_${utxo.id}_${utxo.isBlocked}"), + data: data, + walletId: widget.walletId, + raiseOnSelected: false, + onSelectionChanged: (value) { + setState(() { + if (data.selected) { + _selectedUTXOs.add(value); + } else { + _selectedUTXOs.remove(value); + } + }); + }, + ); + }, + ).toList(), + ); + }, + ), ), ), ], diff --git a/lib/pages_desktop_specific/coin_control/utxo_row.dart b/lib/pages_desktop_specific/coin_control/utxo_row.dart index 0af22ce7d..e5ddf2f06 100644 --- a/lib/pages_desktop_specific/coin_control/utxo_row.dart +++ b/lib/pages_desktop_specific/coin_control/utxo_row.dart @@ -42,12 +42,16 @@ class UtxoRow extends ConsumerStatefulWidget { required this.walletId, this.onSelectionChanged, this.compact = false, + this.compactWithBorder = true, + this.raiseOnSelected = true, }) : super(key: key); final String walletId; final UtxoRowData data; final void Function(UtxoRowData)? onSelectionChanged; final bool compact; + final bool compactWithBorder; + final bool raiseOnSelected; @override ConsumerState createState() => _UtxoRowState(); @@ -96,29 +100,31 @@ class _UtxoRowState extends ConsumerState { } return RoundedContainer( - borderColor: widget.compact + borderColor: widget.compact && widget.compactWithBorder ? Theme.of(context).extension()!.textFieldDefaultBG : null, color: Theme.of(context).extension()!.popupBG, - boxShadow: widget.data.selected + boxShadow: widget.data.selected && widget.raiseOnSelected ? [ Theme.of(context).extension()!.standardBoxShadow, ] : null, child: Row( children: [ - Checkbox( - value: widget.data.selected, - onChanged: (value) { - setState(() { - widget.data.selected = value!; - }); - widget.onSelectionChanged?.call(widget.data); - }, - ), - const SizedBox( - width: 10, - ), + if (!(widget.compact && utxo.isBlocked)) + Checkbox( + value: widget.data.selected, + onChanged: (value) { + setState(() { + widget.data.selected = value!; + }); + widget.onSelectionChanged?.call(widget.data); + }, + ), + if (!(widget.compact && utxo.isBlocked)) + const SizedBox( + width: 10, + ), UTXOStatusIcon( blocked: utxo.isBlocked, status: utxo.isConfirmed( diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart index 61410badc..97375f2e7 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart @@ -122,93 +122,101 @@ class _DesktopSendState extends ConsumerState { Format.decimalAmountToSatoshis(manager.balance.getSpendable(), coin); } - // confirm send all - if (amount == availableBalance) { - final bool? shouldSendAll = await showDialog( - context: context, - useSafeArea: false, - barrierDismissible: true, - builder: (context) { - return DesktopDialog( - maxWidth: 450, - maxHeight: double.infinity, - child: Padding( - padding: const EdgeInsets.only( - left: 32, - bottom: 32, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "Confirm send all", - style: STextStyles.desktopH3(context), - ), - const DesktopDialogCloseButton(), - ], - ), - const SizedBox( - height: 12, - ), - Padding( - padding: const EdgeInsets.only( - right: 32, - ), - child: Text( - "You are about to send your entire balance. Would you like to continue?", - textAlign: TextAlign.left, - style: STextStyles.desktopTextExtraExtraSmall(context) - .copyWith( - fontSize: 18, - ), - ), - ), - const SizedBox( - height: 40, - ), - Padding( - padding: const EdgeInsets.only( - right: 32, - ), - child: Row( + final coinControlEnabled = + ref.read(prefsChangeNotifierProvider).enableCoinControl; + + if (!(manager.hasCoinControlSupport && coinControlEnabled) || + (manager.hasCoinControlSupport && + coinControlEnabled && + ref.read(desktopUseUTXOs).isEmpty)) { + // confirm send all + if (amount == availableBalance) { + final bool? shouldSendAll = await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return DesktopDialog( + maxWidth: 450, + maxHeight: double.infinity, + child: Padding( + padding: const EdgeInsets.only( + left: 32, + bottom: 32, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Expanded( - child: SecondaryButton( - buttonHeight: ButtonHeight.l, - label: "Cancel", - onPressed: () { - Navigator.of(context).pop(false); - }, - ), - ), - const SizedBox( - width: 16, - ), - Expanded( - child: PrimaryButton( - buttonHeight: ButtonHeight.l, - label: "Yes", - onPressed: () { - Navigator.of(context).pop(true); - }, - ), + Text( + "Confirm send all", + style: STextStyles.desktopH3(context), ), + const DesktopDialogCloseButton(), ], ), - ), - ], + const SizedBox( + height: 12, + ), + Padding( + padding: const EdgeInsets.only( + right: 32, + ), + child: Text( + "You are about to send your entire balance. Would you like to continue?", + textAlign: TextAlign.left, + style: STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + fontSize: 18, + ), + ), + ), + const SizedBox( + height: 40, + ), + Padding( + padding: const EdgeInsets.only( + right: 32, + ), + child: Row( + children: [ + Expanded( + child: SecondaryButton( + buttonHeight: ButtonHeight.l, + label: "Cancel", + onPressed: () { + Navigator.of(context).pop(false); + }, + ), + ), + const SizedBox( + width: 16, + ), + Expanded( + child: PrimaryButton( + buttonHeight: ButtonHeight.l, + label: "Yes", + onPressed: () { + Navigator.of(context).pop(true); + }, + ), + ), + ], + ), + ), + ], + ), ), - ), - ); - }, - ); + ); + }, + ); - if (shouldSendAll == null || shouldSendAll == false) { - // cancel preview - return; + if (shouldSendAll == null || shouldSendAll == false) { + // cancel preview + return; + } } } @@ -261,7 +269,14 @@ class _DesktopSendState extends ConsumerState { txDataFuture = wallet.preparePaymentCodeSend( paymentCode: paymentCode, satoshiAmount: amount, - args: {"feeRate": feeRate}, + args: { + "feeRate": feeRate, + "UTXOs": (manager.hasCoinControlSupport && + coinControlEnabled && + ref.read(desktopUseUTXOs).isNotEmpty) + ? ref.read(desktopUseUTXOs) + : null, + }, ); } else if ((coin == Coin.firo || coin == Coin.firoTestNet) && ref.read(publicPrivateBalanceStateProvider.state).state != @@ -269,13 +284,27 @@ class _DesktopSendState extends ConsumerState { txDataFuture = (manager.wallet as FiroWallet).prepareSendPublic( address: _address!, satoshiAmount: amount, - args: {"feeRate": ref.read(feeRateTypeStateProvider)}, + args: { + "feeRate": ref.read(feeRateTypeStateProvider), + "UTXOs": (manager.hasCoinControlSupport && + coinControlEnabled && + ref.read(desktopUseUTXOs).isNotEmpty) + ? ref.read(desktopUseUTXOs) + : null, + }, ); } else { txDataFuture = manager.prepareSend( address: _address!, satoshiAmount: amount, - args: {"feeRate": ref.read(feeRateTypeStateProvider)}, + args: { + "feeRate": ref.read(feeRateTypeStateProvider), + "UTXOs": (manager.hasCoinControlSupport && + coinControlEnabled && + ref.read(desktopUseUTXOs).isNotEmpty) + ? ref.read(desktopUseUTXOs) + : null, + }, ); } @@ -682,13 +711,11 @@ class _DesktopSendState extends ConsumerState { } void _showDesktopCoinControl() async { - if (_amountToSend == null) { - // - } - final result = await showDialog( + await showDialog( context: context, - builder: (context) => DesktopCoinControlUseDialog( + builder: (context) => DesktopCoinControlUseDialog( walletId: widget.walletId, + amountToSend: _amountToSend, ), ); } @@ -1094,7 +1121,9 @@ class _DesktopSendState extends ConsumerState { style: STextStyles.desktopTextExtraExtraSmall(context), ), CustomTextButton( - text: "Select coins", + text: ref.watch(desktopUseUTXOs.state).state.isEmpty + ? "Select coins" + : "Selected coins (${ref.watch(desktopUseUTXOs.state).state.length})", onTap: _showDesktopCoinControl, ), ], diff --git a/lib/widgets/custom_buttons/dropdown_button.dart b/lib/widgets/custom_buttons/dropdown_button.dart index ba8894a1d..fd821fd0b 100644 --- a/lib/widgets/custom_buttons/dropdown_button.dart +++ b/lib/widgets/custom_buttons/dropdown_button.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:stackwallet/utilities/assets.dart'; +import 'package:stackwallet/utilities/constants.dart'; import 'package:stackwallet/utilities/text_styles.dart'; import 'package:stackwallet/utilities/theme/stack_colors.dart'; import 'package:stackwallet/widgets/animated_widgets/rotate_icon.dart'; @@ -123,6 +124,124 @@ class _JDropdownButtonState extends State> { } } +class JDropdownIconButton extends StatefulWidget { + const JDropdownIconButton({ + Key? key, + required this.items, + this.onSelectionChanged, + this.groupValue, + this.redrawOnScreenSizeChanged = false, + }) : super(key: key); + + final void Function(T?)? onSelectionChanged; + final T? groupValue; + final Set items; + + /// setting this to true should be done carefully + final bool redrawOnScreenSizeChanged; + + @override + State> createState() => _JDropdownIconButtonState(); +} + +class _JDropdownIconButtonState extends State> { + final _key = GlobalKey(); + + bool _isOpen = false; + + OverlayEntry? _entry; + + void close() { + if (_isOpen) { + _entry?.remove(); + _isOpen = false; + } + } + + void open() { + final size = (_key.currentContext!.findRenderObject() as RenderBox).size; + _entry = OverlayEntry( + builder: (_) { + final position = (_key.currentContext!.findRenderObject() as RenderBox) + .localToGlobal(Offset.zero); + + if (widget.redrawOnScreenSizeChanged) { + // trigger rebuild + MediaQuery.of(context).size; + } + + return GestureDetector( + onTap: close, + child: _JDropdownButtonMenu( + size: Size(200, size.height), + position: Offset(position.dx - 144, position.dy), + items: widget.items + .map( + (e) => _JDropdownButtonItem( + value: e, + groupValue: widget.groupValue, + onSelected: (T value) { + widget.onSelectionChanged?.call(value); + close(); + }, + ), + ) + .toList(), + ), + ); + }, + ); + Overlay.of(context, rootOverlay: true).insert(_entry!); + _isOpen = true; + } + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + if (widget.redrawOnScreenSizeChanged && _isOpen) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _entry?.markNeedsBuild(); + }); + } + + return SizedBox( + key: _key, + height: 56, + width: 56, + child: TextButton( + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonStyle(context) + ?.copyWith( + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + side: BorderSide( + color: Theme.of(context) + .extension()! + .buttonBackBorderSecondary, + width: 1, + ), + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + ), + ), + onPressed: _isOpen ? close : open, + child: SvgPicture.asset( + Assets.svg.list, + width: 20, + height: 20, + ), + ), + ); + } +} + // ============================================================================= class _JDropdownButtonMenu extends StatefulWidget { diff --git a/lib/widgets/expandable2.dart b/lib/widgets/expandable2.dart new file mode 100644 index 000000000..4439d83e6 --- /dev/null +++ b/lib/widgets/expandable2.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/theme/stack_colors.dart'; + +enum Expandable2State { + collapsed, + expanded, +} + +class Expandable2Controller { + VoidCallback? toggle; + Expandable2State state = Expandable2State.collapsed; +} + +class Expandable2 extends StatefulWidget { + const Expandable2({ + Key? key, + required this.header, + required this.children, + this.background = Colors.white, + this.border = Colors.black, + this.animationController, + this.animation, + this.animationDurationMultiplier = 1.0, + this.onExpandWillChange, + this.onExpandChanged, + this.controller, + this.expandOverride, + }) : super(key: key); + + final Widget header; + final List children; + final Color background; + final Color border; + final AnimationController? animationController; + final Animation? animation; + final double animationDurationMultiplier; + final void Function(Expandable2State)? onExpandWillChange; + final void Function(Expandable2State)? onExpandChanged; + final Expandable2Controller? controller; + final VoidCallback? expandOverride; + + @override + State createState() => _Expandable2State(); +} + +class _Expandable2State extends State + with TickerProviderStateMixin { + final _key = GlobalKey(); + + late final AnimationController animationController; + late final Animation animation; + late final Duration duration; + late final Expandable2Controller? controller; + + Expandable2State _toggleState = Expandable2State.collapsed; + + void toggle() { + if (animation.isDismissed) { + _toggleState = Expandable2State.expanded; + widget.onExpandWillChange?.call(_toggleState); + animationController + .forward() + .then((_) => widget.onExpandChanged?.call(_toggleState)); + } else if (animation.isCompleted) { + _toggleState = Expandable2State.collapsed; + widget.onExpandWillChange?.call(_toggleState); + animationController + .reverse() + .then((_) => widget.onExpandChanged?.call(_toggleState)); + } + controller?.state = _toggleState; + setState(() {}); + } + + @override + void initState() { + controller = widget.controller; + controller?.toggle = toggle; + + duration = Duration( + milliseconds: (500 * widget.animationDurationMultiplier).toInt(), + ); + animationController = widget.animationController ?? + AnimationController( + vsync: this, + duration: duration, + ); + animation = widget.animation ?? + Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + curve: Curves.easeInOut, + parent: animationController, + ), + ); + super.initState(); + } + + double _top = 0; + + void getHeaderHeight() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_key.currentContext?.size?.height != null && + _top != _key.currentContext!.size!.height) { + setState(() { + _top = _key.currentContext!.size!.height; + }); + } + }); + } + + @override + void dispose() { + animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + getHeaderHeight(); + + return AnimatedContainer( + duration: duration, + decoration: _toggleState == Expandable2State.expanded + ? BoxDecoration( + color: widget.background, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + border: Border.all(color: widget.border), + boxShadow: [ + Theme.of(context).extension()!.standardBoxShadow, + ], + ) + : BoxDecoration( + color: widget.background, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + border: Border.all(color: widget.border), + ), + child: Stack( + children: [ + Padding( + padding: EdgeInsets.only(top: _top), + child: SizeTransition( + sizeFactor: animation, + axisAlignment: 1.0, + child: Column( + children: widget.children + .map( + (e) => Column( + children: [ + Container( + height: 1, + width: double.infinity, + color: widget.border, + ), + e, + ], + ), + ) + .toList(), + ), + ), + ), + MouseRegion( + key: _key, + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: widget.expandOverride ?? toggle, + child: Container( + color: Colors.transparent, + child: widget.header, + ), + ), + ), + ], + ), + ); + } +}