diff --git a/lib/pages/coin_control/coin_control_rules.dart b/lib/pages/coin_control/coin_control_rules.dart new file mode 100644 index 0000000000..97b0471a92 --- /dev/null +++ b/lib/pages/coin_control/coin_control_rules.dart @@ -0,0 +1,24 @@ +/* + * 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. + */ + +import '../../db/isar/main_db.dart'; + +CCFilter coinControlFilter({ + required bool isSearching, + required bool showBlocked, +}) => isSearching + ? CCFilter.all + : showBlocked + ? CCFilter.frozen + : CCFilter.available; + +bool canSelectCoinControlOutput({ + required bool isManageMode, + required bool isBlocked, + required bool isConfirmed, +}) => isManageMode || (!isBlocked && isConfirmed); diff --git a/lib/pages/coin_control/coin_control_view.dart b/lib/pages/coin_control/coin_control_view.dart index 7960733643..6a4bb4c2e0 100644 --- a/lib/pages/coin_control/coin_control_view.dart +++ b/lib/pages/coin_control/coin_control_view.dart @@ -41,6 +41,7 @@ import '../../widgets/icon_widgets/x_icon.dart'; import '../../widgets/rounded_container.dart'; import '../../widgets/rounded_white_container.dart'; import '../../widgets/toggle.dart'; +import 'coin_control_rules.dart'; import 'utxo_card.dart'; import 'utxo_details_view.dart'; @@ -123,21 +124,19 @@ class _CoinControlViewState extends ConsumerState { Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); - final minConfirms = - ref - .watch(pWallets) - .getWallet(widget.walletId) - .cryptoCurrency - .minConfirms; - final coin = ref.watch(pWalletCoin(widget.walletId)); final currentHeight = ref.watch(pWalletChainHeight(widget.walletId)); + final filter = coinControlFilter( + isSearching: _isSearching, + showBlocked: _showBlocked, + ); + if (_sort == CCSortDescriptor.address && !_isSearching) { _list = null; _map = MainDB.instance.queryUTXOsGroupedByAddressSync( walletId: widget.walletId, - filter: CCFilter.all, + filter: filter, sort: _sort, searchTerm: "", cryptoCurrency: coin, @@ -146,12 +145,7 @@ class _CoinControlViewState extends ConsumerState { _map = null; _list = MainDB.instance.queryUTXOsSync( walletId: widget.walletId, - filter: - _isSearching - ? CCFilter.all - : _showBlocked - ? CCFilter.frozen - : CCFilter.available, + filter: filter, sort: _sort, searchTerm: _isSearching ? searchController.text : "", cryptoCurrency: coin, @@ -168,115 +162,107 @@ class _CoinControlViewState extends ConsumerState { }, child: Background( child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of( + context, + ).extension()!.background, appBar: AppBar( automaticallyImplyLeading: false, - leading: - _isSearching - ? null - : widget.type == CoinControlViewType.use && - _selectedAvailable.isNotEmpty - ? AppBarIconButton( - icon: XIcon( - width: 24, - height: 24, - color: - Theme.of( - context, - ).extension()!.topNavIconPrimary, - ), - onPressed: () { - setState(() { - _selectedAvailable.clear(); - }); - }, - ) - : AppBarBackButton( - onPressed: () { - unawaited(_refreshBalance()); - Navigator.of(context).pop( - widget.type == CoinControlViewType.use - ? _selectedAvailable - : null, - ); - }, - ), - title: - _isSearching - ? AppBarSearchField( - controller: searchController, - focusNode: searchFocus, - ) - : Text( - "Coin control", - style: STextStyles.navBarTitle(context), + leading: _isSearching + ? null + : widget.type == CoinControlViewType.use && + _selectedAvailable.isNotEmpty + ? AppBarIconButton( + icon: XIcon( + width: 24, + height: 24, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, ), + onPressed: () { + setState(() { + _selectedAvailable.clear(); + }); + }, + ) + : AppBarBackButton( + onPressed: () { + unawaited(_refreshBalance()); + Navigator.of(context).pop( + widget.type == CoinControlViewType.use + ? _selectedAvailable + : null, + ); + }, + ), + title: _isSearching + ? AppBarSearchField( + controller: searchController, + focusNode: searchFocus, + ) + : Text("Coin control", style: STextStyles.navBarTitle(context)), titleSpacing: 0, - actions: - _isSearching - ? [ - AspectRatio( - aspectRatio: 1, - child: AppBarIconButton( - size: 36, - icon: SvgPicture.asset( - Assets.svg.x, - width: 20, - height: 20, - color: - Theme.of( - context, - ).extension()!.topNavIconPrimary, - ), - onPressed: () { - // show search - setState(() { - _isSearching = false; - }); - }, + actions: _isSearching + ? [ + AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + size: 36, + icon: SvgPicture.asset( + Assets.svg.x, + width: 20, + height: 20, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, ), + onPressed: () { + // show search + setState(() { + _isSearching = false; + }); + }, ), - ] - : [ - AspectRatio( - aspectRatio: 1, - child: AppBarIconButton( - size: 36, - icon: SvgPicture.asset( - Assets.svg.search, - width: 20, - height: 20, - color: - Theme.of( - context, - ).extension()!.topNavIconPrimary, - ), - onPressed: () { - // show search - setState(() { - _isSearching = true; - }); - }, + ), + ] + : [ + AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + size: 36, + icon: SvgPicture.asset( + Assets.svg.search, + width: 20, + height: 20, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, ), + onPressed: () { + // show search + setState(() { + _isSearching = true; + }); + }, ), - AspectRatio( - aspectRatio: 1, - child: JDropdownIconButton( - mobileAppBar: true, - groupValue: _sort, - items: CCSortDescriptor.values.toSet(), - onSelectionChanged: (CCSortDescriptor? newValue) { - if (newValue != null && newValue != _sort) { - setState(() { - _sort = newValue; - }); - } - }, - displayPrefix: "Sort by", - ), + ), + AspectRatio( + aspectRatio: 1, + child: JDropdownIconButton( + mobileAppBar: true, + groupValue: _sort, + items: CCSortDescriptor.values.toSet(), + onSelectionChanged: (CCSortDescriptor? newValue) { + if (newValue != null && newValue != _sort) { + setState(() { + _sort = newValue; + }); + } + }, + displayPrefix: "Sort by", ), - ], + ), + ], ), body: SafeArea( child: Column( @@ -291,31 +277,28 @@ class _CoinControlViewState extends ConsumerState { RoundedWhiteContainer( child: Text( "This option allows you to control, freeze, and utilize " - "outputs at your discretion. Tap the output circle to " - "select.", + "outputs at your discretion. Tap an output to select it, " + "or use the options button for more actions.", style: STextStyles.w500_14(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textSubtitle1, + color: Theme.of( + context, + ).extension()!.textSubtitle1, ), ), ), if (!_isSearching) const SizedBox(height: 10), - if (!(_isSearching || _map != null)) + if (!_isSearching) SizedBox( height: 48, child: Toggle( key: UniqueKey(), - onColor: - Theme.of( - context, - ).extension()!.popupBG, + onColor: Theme.of( + context, + ).extension()!.popupBG, onText: "Available outputs", - offColor: - Theme.of(context) - .extension()! - .textFieldDefaultBG, + offColor: Theme.of( + context, + ).extension()!.textFieldDefaultBG, offText: "Frozen outputs", isOn: _showBlocked, onValueChanged: (value) { @@ -336,14 +319,13 @@ class _CoinControlViewState extends ConsumerState { Expanded( child: ListView.separated( itemCount: _list!.length, - separatorBuilder: - (context, _) => const SizedBox(height: 10), + separatorBuilder: (context, _) => + const SizedBox(height: 10), itemBuilder: (context, index) { - final utxo = - MainDB.instance.isar.utxos - .where() - .idEqualTo(_list![index]) - .findFirstSync()!; + final utxo = MainDB.instance.isar.utxos + .where() + .idEqualTo(_list![index]) + .findFirstSync()!; final isSelected = _selectedBlocked.contains(utxo) || @@ -355,22 +337,21 @@ class _CoinControlViewState extends ConsumerState { ), walletId: widget.walletId, utxo: utxo, - canSelect: - widget.type == - CoinControlViewType.manage || - (widget.type == CoinControlViewType.use && - !utxo.isBlocked && - _isConfirmed( - utxo, - currentHeight, - ref.watch( - pWallets.select( - (s) => s.getWallet( - widget.walletId, - ), - ), - ), - )), + canSelect: canSelectCoinControlOutput( + isManageMode: + widget.type == + CoinControlViewType.manage, + isBlocked: utxo.isBlocked, + isConfirmed: _isConfirmed( + utxo, + currentHeight, + ref.watch( + pWallets.select( + (s) => s.getWallet(widget.walletId), + ), + ), + ), + ), initialSelectedState: isSelected, onSelectedChanged: (value) { if (value) { @@ -384,16 +365,15 @@ class _CoinControlViewState extends ConsumerState { } setState(() {}); }, - onPressed: () async { - final result = await Navigator.of( - context, - ).pushNamed( - UtxoDetailsView.routeName, - arguments: Tuple2( - utxo.id, - widget.walletId, - ), - ); + onOptionsPressed: () async { + final result = await Navigator.of(context) + .pushNamed( + UtxoDetailsView.routeName, + arguments: Tuple2( + utxo.id, + widget.walletId, + ), + ); if (mounted && result == "refresh") { setState(() {}); } @@ -405,244 +385,236 @@ class _CoinControlViewState extends ConsumerState { if (!_isSearching) _list != null ? Expanded( - child: 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()!; + child: 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 isSelected = - _showBlocked - ? _selectedBlocked.contains(utxo) - : _selectedAvailable.contains(utxo); + 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 && - _isConfirmed( - utxo, - currentHeight, - ref.watch( - pWallets.select( - (s) => s.getWallet( - widget.walletId, - ), - ), + return UtxoCard( + key: Key( + "${utxo.walletId}_${utxo.id}_$isSelected", + ), + walletId: widget.walletId, + utxo: utxo, + canSelect: canSelectCoinControlOutput( + isManageMode: + widget.type == + CoinControlViewType.manage, + isBlocked: utxo.isBlocked, + isConfirmed: _isConfirmed( + utxo, + currentHeight, + ref.watch( + pWallets.select( + (s) => s.getWallet( + widget.walletId, ), - )), - 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, + ), + ), ), - ); - if (mounted && result == "refresh") { + ), + initialSelectedState: isSelected, + onSelectedChanged: (value) { + if (value) { + _showBlocked + ? _selectedBlocked.add(utxo) + : _selectedAvailable.add(utxo); + } else { + _showBlocked + ? _selectedBlocked.remove(utxo) + : _selectedAvailable.remove( + utxo, + ); + } setState(() {}); - } - }, - ); - }, - ), - ) + }, + onOptionsPressed: () async { + final result = + await Navigator.of( + context, + ).pushNamed( + UtxoDetailsView.routeName, + arguments: Tuple2( + utxo.id, + widget.walletId, + ), + ); + if (mounted && result == "refresh") { + setState(() {}); + } + }, + ); + }, + ), + ) : Expanded( - child: ListView.separated( - itemCount: _map!.entries.length, - separatorBuilder: - (context, _) => - const SizedBox(height: 10), - itemBuilder: (context, index) { - final entry = _map!.entries.elementAt( - index, - ); - final _controller = RotateIconController(); + child: 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(14), - color: Colors.transparent, - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - entry.key, - style: STextStyles.w600_14( - context, + 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(14), + color: Colors.transparent, + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + entry.key, + style: + STextStyles.w600_14( + context, + ), ), - ), - const SizedBox(height: 2), - Text( - "${entry.value.length} " - "output${entry.value.length > 1 ? "s" : ""}", - style: STextStyles.w500_12( - context, - ).copyWith( - color: - Theme.of(context) - .extension< - StackColors - >()! - .textSubtitle1, + const SizedBox(height: 2), + Text( + "${entry.value.length} " + "output${entry.value.length > 1 ? "s" : ""}", + style: + STextStyles.w500_12( + context, + ).copyWith( + color: Theme.of(context) + .extension< + StackColors + >()! + .textSubtitle1, + ), ), - ), - ], + ], + ), ), - ), - RotateIcon( - animationDurationMultiplier: - 0.2 * entry.value.length, - icon: SvgPicture.asset( - Assets.svg.chevronDown, - width: 14, - color: - Theme.of(context) - .extension< - StackColors - >()! - .textSubtitle1, + 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, ), - curve: Curves.easeInOut, - controller: _controller, - ), - ], + ], + ), ), - ), - children: - entry.value.map((id) { - final utxo = - MainDB.instance.isar.utxos - .where() - .idEqualTo(id) - .findFirstSync()!; + children: entry.value.map((id) { + final utxo = MainDB + .instance + .isar + .utxos + .where() + .idEqualTo(id) + .findFirstSync()!; - final isSelected = - _selectedBlocked.contains( - utxo, - ) || - _selectedAvailable.contains( - utxo, - ); + final isSelected = + _selectedBlocked.contains(utxo) || + _selectedAvailable.contains(utxo); - return UtxoCard( - key: Key( - "${utxo.walletId}_${utxo.id}_$isSelected", - ), - walletId: widget.walletId, - utxo: utxo, - canSelect: - widget.type == + return UtxoCard( + key: Key( + "${utxo.walletId}_${utxo.id}_$isSelected", + ), + walletId: widget.walletId, + utxo: utxo, + canSelect: + canSelectCoinControlOutput( + isManageMode: + widget.type == CoinControlViewType - .manage || - (widget.type == - CoinControlViewType - .use && - !utxo.isBlocked && - _isConfirmed( - utxo, - currentHeight, - ref.watch( - pWallets.select( - (s) => s.getWallet( - widget.walletId, - ), - ), + .manage, + isBlocked: utxo.isBlocked, + isConfirmed: _isConfirmed( + utxo, + currentHeight, + ref.watch( + pWallets.select( + (s) => s.getWallet( + widget.walletId, ), - )), - initialSelectedState: isSelected, - onSelectedChanged: (value) { - if (value) { - utxo.isBlocked - ? _selectedBlocked.add( - utxo, - ) - : _selectedAvailable.add( + ), + ), + ), + ), + initialSelectedState: isSelected, + onSelectedChanged: (value) { + if (value) { + utxo.isBlocked + ? _selectedBlocked.add(utxo) + : _selectedAvailable.add( utxo, ); - } else { - utxo.isBlocked - ? _selectedBlocked.remove( + } else { + utxo.isBlocked + ? _selectedBlocked.remove( utxo, ) - : _selectedAvailable - .remove(utxo); - } + : _selectedAvailable.remove( + utxo, + ); + } + setState(() {}); + }, + onOptionsPressed: () async { + final result = + await Navigator.of( + context, + ).pushNamed( + UtxoDetailsView.routeName, + arguments: Tuple2( + utxo.id, + widget.walletId, + ), + ); + if (mounted && + result == "refresh") { setState(() {}); - }, - onPressed: () async { - final result = - await Navigator.of( - context, - ).pushNamed( - UtxoDetailsView.routeName, - arguments: Tuple2( - utxo.id, - widget.walletId, - ), - ); - if (mounted && - result == "refresh") { - setState(() {}); - } - }, - ); - }).toList(), - ); - }, + } + }, + ); + }).toList(), + ); + }, + ), ), - ), ], ), ), @@ -652,10 +624,9 @@ class _CoinControlViewState extends ConsumerState { widget.type == CoinControlViewType.manage) Container( decoration: BoxDecoration( - color: - Theme.of( - context, - ).extension()!.backgroundAppBar, + color: Theme.of( + context, + ).extension()!.backgroundAppBar, boxShadow: [ Theme.of( context, @@ -690,10 +661,9 @@ class _CoinControlViewState extends ConsumerState { if (!_showBlocked && widget.type == CoinControlViewType.use) Container( decoration: BoxDecoration( - color: - Theme.of( - context, - ).extension()!.backgroundAppBar, + color: Theme.of( + context, + ).extension()!.backgroundAppBar, boxShadow: [ Theme.of( context, @@ -722,13 +692,13 @@ class _CoinControlViewState extends ConsumerState { builder: (context) { final int selectedSumInt = _selectedAvailable.isEmpty - ? 0 - : _selectedAvailable - .map((e) => e.value) - .reduce( - (value, element) => - value += element, - ); + ? 0 + : _selectedAvailable + .map((e) => e.value) + .reduce( + (value, element) => + value += element, + ); final selectedSum = selectedSumInt .toAmountAsRaw( fractionDigits: @@ -738,33 +708,26 @@ class _CoinControlViewState extends ConsumerState { ref .watch(pAmountFormatter(coin)) .format(selectedSum), - 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, - ), + 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, + ), ); }, ), @@ -775,10 +738,9 @@ class _CoinControlViewState extends ConsumerState { Container( width: double.infinity, height: 1.5, - color: - Theme.of(context) - .extension()! - .backgroundAppBar, + color: Theme.of(context) + .extension()! + .backgroundAppBar, ), if (widget.requestedTotal != null) Padding( diff --git a/lib/pages/coin_control/selectable_utxo_surface.dart b/lib/pages/coin_control/selectable_utxo_surface.dart new file mode 100644 index 0000000000..3c9505b34f --- /dev/null +++ b/lib/pages/coin_control/selectable_utxo_surface.dart @@ -0,0 +1,57 @@ +/* + * 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. + */ + +import 'package:flutter/material.dart'; + +import '../../utilities/constants.dart'; + +class SelectableUtxoSurface extends StatelessWidget { + const SelectableUtxoSurface({ + super.key, + required this.canSelect, + required this.selected, + required this.onToggle, + required this.child, + this.color, + }); + + final bool canSelect; + final bool selected; + final VoidCallback onToggle; + final Widget child; + final Color? color; + + @override + Widget build(BuildContext context) { + if (!canSelect) { + return child; + } + + return Semantics( + selected: selected, + child: MaterialButton( + minWidth: 0, + padding: EdgeInsets.zero, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + color: color, + elevation: 0, + disabledElevation: 0, + hoverElevation: 0, + focusElevation: 0, + highlightElevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + onPressed: onToggle, + child: child, + ), + ); + } +} diff --git a/lib/pages/coin_control/utxo_card.dart b/lib/pages/coin_control/utxo_card.dart index 624b41eee9..353ea44244 100644 --- a/lib/pages/coin_control/utxo_card.dart +++ b/lib/pages/coin_control/utxo_card.dart @@ -10,6 +10,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import '../../db/isar/main_db.dart'; import '../../models/isar/models/isar_models.dart'; @@ -17,14 +18,15 @@ import '../../providers/global/wallets_provider.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/amount/amount.dart'; import '../../utilities/amount/amount_formatter.dart'; -import '../../utilities/constants.dart'; +import '../../utilities/assets.dart'; import '../../utilities/text_styles.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../../wallets/wallet/impl/namecoin_wallet.dart'; import '../../wallets/wallet/wallet.dart'; -import '../../widgets/conditional_parent.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/icon_widgets/utxo_status_icon.dart'; import '../../widgets/rounded_container.dart'; +import 'selectable_utxo_surface.dart'; class UtxoCard extends ConsumerStatefulWidget { const UtxoCard({ @@ -34,14 +36,14 @@ class UtxoCard extends ConsumerStatefulWidget { required this.onSelectedChanged, required this.initialSelectedState, required this.canSelect, - this.onPressed, + this.onOptionsPressed, }); final String walletId; final UTXO utxo; final void Function(bool) onSelectedChanged; final bool initialSelectedState; - final VoidCallback? onPressed; + final VoidCallback? onOptionsPressed; final bool canSelect; @override @@ -75,6 +77,25 @@ class _UtxoCardState extends ConsumerState { super.initState(); } + void _setSelected(bool selected) { + if (_selected == selected) { + return; + } + _selected = selected; + widget.onSelectedChanged(_selected); + setState(() {}); + } + + void _toggleSelected() => _setSelected(!_selected); + + @override + void didUpdateWidget(covariant UtxoCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.initialSelectedState != widget.initialSelectedState) { + _selected = widget.initialSelectedState; + } + } + @override Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); @@ -82,28 +103,13 @@ class _UtxoCardState extends ConsumerState { final coin = ref.watch(pWalletCoin(widget.walletId)); final currentHeight = ref.watch(pWalletChainHeight(widget.walletId)); - return ConditionalParent( - condition: widget.onPressed != null, - builder: (child) => MaterialButton( - padding: const EdgeInsets.all(0), - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - color: Theme.of(context).extension()!.popupBG, - elevation: 0, - disabledElevation: 0, - hoverElevation: 0, - focusElevation: 0, - highlightElevation: 0, - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(Constants.size.circularBorderRadius), - ), - onPressed: widget.onPressed, - child: child, - ), + return SelectableUtxoSurface( + canSelect: widget.canSelect, + selected: _selected, + onToggle: _toggleSelected, + color: Theme.of(context).extension()!.popupBG, child: RoundedContainer( - color: widget.onPressed == null - ? Theme.of(context).extension()!.popupBG - : Colors.transparent, + color: Theme.of(context).extension()!.popupBG, child: StreamBuilder( stream: stream, builder: (context, snapshot) { @@ -112,57 +118,42 @@ class _UtxoCardState extends ConsumerState { } return Row( children: [ - ConditionalParent( - condition: widget.canSelect, - builder: (child) => GestureDetector( - onTap: () { - _selected = !_selected; - widget.onSelectedChanged(_selected); - setState(() {}); - }, - child: child, - ), - child: UTXOStatusIcon( - blocked: utxo.isBlocked, - status: _isConfirmed( - utxo, - currentHeight, - ref.watch( - pWallets.select( - (s) => s.getWallet( - widget.walletId, - ), + UTXOStatusIcon( + blocked: utxo.isBlocked, + status: + _isConfirmed( + utxo, + currentHeight, + ref.watch( + pWallets.select((s) => s.getWallet(widget.walletId)), ), - ), - ) - ? UTXOStatusIconStatus.confirmed - : UTXOStatusIconStatus.unconfirmed, - background: - Theme.of(context).extension()!.popupBG, - selected: _selected, - width: 32, - height: 32, - ), - ), - const SizedBox( - width: 10, + ) + ? UTXOStatusIconStatus.confirmed + : UTXOStatusIconStatus.unconfirmed, + background: Theme.of( + context, + ).extension()!.popupBG, + selected: _selected, + width: 32, + height: 32, ), + const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( - ref.watch(pAmountFormatter(coin)).format( + ref + .watch(pAmountFormatter(coin)) + .format( utxo.value.toAmountAsRaw( fractionDigits: coin.fractionDigits, ), ), style: STextStyles.w600_14(context), ), - const SizedBox( - height: 2, - ), + const SizedBox(height: 2), Row( children: [ Flexible( @@ -171,9 +162,9 @@ class _UtxoCardState extends ConsumerState { ? utxo.name : utxo.address ?? utxo.txid, style: STextStyles.w500_12(context).copyWith( - color: Theme.of(context) - .extension()! - .textSubtitle1, + color: Theme.of( + context, + ).extension()!.textSubtitle1, ), ), ), @@ -182,6 +173,27 @@ class _UtxoCardState extends ConsumerState { ], ), ), + if (widget.onOptionsPressed != null) const SizedBox(width: 10), + if (widget.onOptionsPressed != null) + AppBarIconButton( + semanticsLabel: "Output options", + tooltip: "Output options", + size: 36, + shadows: const [], + color: Theme.of(context).extension()!.popupBG, + icon: SvgPicture.asset( + Assets.svg.verticalEllipsis, + colorFilter: ColorFilter.mode( + Theme.of( + context, + ).extension()!.textSubtitle1, + BlendMode.srcIn, + ), + width: 20, + height: 20, + ), + onPressed: widget.onOptionsPressed, + ), ], ); }, diff --git a/lib/pages_desktop_specific/coin_control/utxo_row.dart b/lib/pages_desktop_specific/coin_control/utxo_row.dart index e27d970fc5..0c180623c8 100644 --- a/lib/pages_desktop_specific/coin_control/utxo_row.dart +++ b/lib/pages_desktop_specific/coin_control/utxo_row.dart @@ -10,22 +10,24 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:isar_community/isar.dart'; import '../../db/isar/main_db.dart'; import '../../models/isar/models/isar_models.dart'; +import '../../pages/coin_control/selectable_utxo_surface.dart'; import '../../pages/coin_control/utxo_details_view.dart'; import '../../providers/global/wallets_provider.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/amount/amount.dart'; import '../../utilities/amount/amount_formatter.dart'; +import '../../utilities/assets.dart'; import '../../utilities/text_styles.dart'; import '../../wallets/crypto_currency/coins/namecoin.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../../wallets/wallet/impl/namecoin_wallet.dart'; import '../../widgets/conditional_parent.dart'; -import '../../widgets/custom_buttons/blue_text_button.dart'; -import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/icon_widgets/utxo_status_icon.dart'; import '../../widgets/rounded_container.dart'; @@ -78,19 +80,34 @@ class _UtxoRowState extends ConsumerState { void _details() async { await showDialog( context: context, - builder: - (context) => - UtxoDetailsView(utxoId: utxo.id, walletId: widget.walletId), + builder: (context) => + UtxoDetailsView(utxoId: utxo.id, walletId: widget.walletId), ); } + void _toggleSelected() { + if (widget.compact && utxo.isBlocked) { + return; + } + _setSelected(!widget.data.selected); + } + + void _setSelected(bool selected) { + if (widget.data.selected == selected) { + return; + } + setState(() { + widget.data.selected = selected; + }); + widget.onSelectionChanged?.call(widget.data); + } + @override void initState() { - utxo = - MainDB.instance.isar.utxos - .where() - .idEqualTo(widget.data.utxoId) - .findFirstSync()!; + utxo = MainDB.instance.isar.utxos + .where() + .idEqualTo(widget.data.utxoId) + .findFirstSync()!; stream = MainDB.instance.watchUTXO(id: utxo.id); super.initState(); @@ -109,32 +126,23 @@ class _UtxoRowState extends ConsumerState { utxo = snapshot.data!; } - return RoundedContainer( - borderColor: - widget.compact && widget.compactWithBorder - ? Theme.of( - context, - ).extension()!.textFieldDefaultBG - : null, + final content = RoundedContainer( + borderColor: widget.compact && widget.compactWithBorder + ? Theme.of(context).extension()!.textFieldDefaultBG + : null, color: Theme.of(context).extension()!.popupBG, - boxShadow: - widget.data.selected && widget.raiseOnSelected - ? [ - Theme.of( - context, - ).extension()!.standardBoxShadow, - ] - : null, + boxShadow: widget.data.selected && widget.raiseOnSelected + ? [Theme.of(context).extension()!.standardBoxShadow] + : null, child: Row( children: [ if (!(widget.compact && utxo.isBlocked)) Checkbox( value: widget.data.selected, onChanged: (value) { - setState(() { - widget.data.selected = value!; - }); - widget.onSelectionChanged?.call(widget.data); + if (value != null) { + _setSelected(value); + } }, ), if (!(widget.compact && utxo.isBlocked)) @@ -143,21 +151,19 @@ class _UtxoRowState extends ConsumerState { blocked: utxo.isBlocked, status: (coin is Namecoin - ? (ref.watch(pWallets).getWallet(widget.walletId) - as NamecoinWallet) - .checkUtxoConfirmed( - utxo, - ref.watch( - pWalletChainHeight(widget.walletId), - ), - ) - : utxo.isConfirmed( - ref.watch(pWalletChainHeight(widget.walletId)), - coin.minConfirms, - coin.minCoinbaseConfirms, - )) - ? UTXOStatusIconStatus.confirmed - : UTXOStatusIconStatus.unconfirmed, + ? (ref.watch(pWallets).getWallet(widget.walletId) + as NamecoinWallet) + .checkUtxoConfirmed( + utxo, + ref.watch(pWalletChainHeight(widget.walletId)), + ) + : utxo.isConfirmed( + ref.watch(pWalletChainHeight(widget.walletId)), + coin.minConfirms, + coin.minCoinbaseConfirms, + )) + ? UTXOStatusIconStatus.confirmed + : UTXOStatusIconStatus.unconfirmed, background: Theme.of(context).extension()!.popupBG, selected: false, width: 32, @@ -207,29 +213,45 @@ class _UtxoRowState extends ConsumerState { utxo.name.isNotEmpty ? utxo.name : utxo.address ?? utxo.txid, - textAlign: - widget.compact ? TextAlign.left : TextAlign.center, + textAlign: widget.compact + ? TextAlign.left + : TextAlign.center, style: STextStyles.w500_12(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textSubtitle1, + color: Theme.of( + context, + ).extension()!.textSubtitle1, ), ), ), ), const SizedBox(width: 10), - widget.compact - ? CustomTextButton(text: "Details", onTap: _details) - : SecondaryButton( - width: 120, - buttonHeight: ButtonHeight.xs, - label: "Details", - onPressed: _details, + AppBarIconButton( + semanticsLabel: "Output options", + tooltip: "Output options", + size: 36, + shadows: const [], + color: Theme.of(context).extension()!.popupBG, + icon: SvgPicture.asset( + Assets.svg.verticalEllipsis, + colorFilter: ColorFilter.mode( + Theme.of(context).extension()!.textSubtitle1, + BlendMode.srcIn, ), + width: 20, + height: 20, + ), + onPressed: _details, + ), ], ), ); + + return SelectableUtxoSurface( + canSelect: !(widget.compact && utxo.isBlocked), + selected: widget.data.selected, + onToggle: _toggleSelected, + child: content, + ); }, ); } diff --git a/test/pages/coin_control/coin_control_rules_test.dart b/test/pages/coin_control/coin_control_rules_test.dart new file mode 100644 index 0000000000..f316b3fc4c --- /dev/null +++ b/test/pages/coin_control/coin_control_rules_test.dart @@ -0,0 +1,55 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/db/isar/main_db.dart'; +import 'package:stackwallet/pages/coin_control/coin_control_rules.dart'; + +void main() { + test("uses one filter contract for flat and grouped output lists", () { + expect( + coinControlFilter(isSearching: true, showBlocked: false), + CCFilter.all, + ); + expect( + coinControlFilter(isSearching: false, showBlocked: false), + CCFilter.available, + ); + expect( + coinControlFilter(isSearching: false, showBlocked: true), + CCFilter.frozen, + ); + }); + + test("use mode excludes blocked and unconfirmed outputs", () { + expect( + canSelectCoinControlOutput( + isManageMode: false, + isBlocked: false, + isConfirmed: true, + ), + isTrue, + ); + expect( + canSelectCoinControlOutput( + isManageMode: false, + isBlocked: true, + isConfirmed: true, + ), + isFalse, + ); + expect( + canSelectCoinControlOutput( + isManageMode: false, + isBlocked: false, + isConfirmed: false, + ), + isFalse, + ); + expect( + canSelectCoinControlOutput( + isManageMode: true, + isBlocked: true, + isConfirmed: false, + ), + isTrue, + ); + }); +} diff --git a/test/widget_tests/selectable_utxo_surface_test.dart b/test/widget_tests/selectable_utxo_surface_test.dart new file mode 100644 index 0000000000..7bcb70c213 --- /dev/null +++ b/test/widget_tests/selectable_utxo_surface_test.dart @@ -0,0 +1,116 @@ +import 'dart:ui' show Tristate; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/pages/coin_control/selectable_utxo_surface.dart'; + +void main() { + Widget testApp({ + required bool canSelect, + required bool selected, + required VoidCallback onToggle, + required VoidCallback onOptions, + }) => MaterialApp( + home: Scaffold( + body: SelectableUtxoSurface( + canSelect: canSelect, + selected: selected, + onToggle: onToggle, + child: SizedBox( + width: 300, + height: 64, + child: Row( + children: [ + const Expanded(child: Text("Output 1")), + IconButton( + key: const Key("options"), + onPressed: onOptions, + icon: const Icon(Icons.more_vert), + ), + ], + ), + ), + ), + ), + ); + + testWidgets("row and nested options have independent actions", ( + tester, + ) async { + var toggles = 0; + var options = 0; + + await tester.pumpWidget( + testApp( + canSelect: true, + selected: false, + onToggle: () => toggles++, + onOptions: () => options++, + ), + ); + + await tester.tap(find.text("Output 1")); + await tester.pump(); + expect(toggles, 1); + expect(options, 0); + + await tester.tap(find.byKey(const Key("options"))); + await tester.pump(); + expect(toggles, 1); + expect(options, 1); + }); + + testWidgets("row selection supports keyboard activation and semantics", ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + var toggles = 0; + + await tester.pumpWidget( + testApp( + canSelect: true, + selected: true, + onToggle: () => toggles++, + onOptions: () {}, + ), + ); + + final selectedSurface = find.byWidgetPredicate( + (widget) => widget is Semantics && widget.properties.selected == true, + ); + expect( + tester.getSemantics(selectedSurface).flagsCollection.isSelected, + Tristate.isTrue, + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(toggles, 1); + + semantics.dispose(); + }); + + testWidgets("nonselectable rows retain their options action", (tester) async { + var toggles = 0; + var options = 0; + + await tester.pumpWidget( + testApp( + canSelect: false, + selected: false, + onToggle: () => toggles++, + onOptions: () => options++, + ), + ); + + await tester.tap(find.text("Output 1")); + await tester.tap(find.byKey(const Key("options"))); + await tester.pump(); + + expect(toggles, 0); + expect(options, 1); + }); +}