diff --git a/asset_sources/svg/campfire/churn.svg b/asset_sources/svg/campfire/churn.svg new file mode 100644 index 000000000..12929f15b --- /dev/null +++ b/asset_sources/svg/campfire/churn.svg @@ -0,0 +1,3 @@ + + + diff --git a/asset_sources/svg/stack_duo/churn.svg b/asset_sources/svg/stack_duo/churn.svg new file mode 100644 index 000000000..12929f15b --- /dev/null +++ b/asset_sources/svg/stack_duo/churn.svg @@ -0,0 +1,3 @@ + + + diff --git a/asset_sources/svg/stack_wallet/churn.svg b/asset_sources/svg/stack_wallet/churn.svg new file mode 100644 index 000000000..12929f15b --- /dev/null +++ b/asset_sources/svg/stack_wallet/churn.svg @@ -0,0 +1,3 @@ + + + diff --git a/lib/pages/churning/churn_error_dialog.dart b/lib/pages/churning/churn_error_dialog.dart new file mode 100644 index 000000000..c9335faff --- /dev/null +++ b/lib/pages/churning/churn_error_dialog.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../providers/churning/churning_service_provider.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../widgets/conditional_parent.dart'; +import '../../widgets/desktop/desktop_dialog.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/stack_dialog.dart'; + +class ChurnErrorDialog extends ConsumerWidget { + const ChurnErrorDialog({ + super.key, + required this.error, + required this.walletId, + }); + + final String error; + final String walletId; + + static const errorTitle = "An error occurred"; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopDialog( + maxHeight: double.infinity, + child: child, + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => StackDialogBase( + child: child, + ), + child: Column( + children: [ + Util.isDesktop + ? Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32, top: 32), + child: Text( + errorTitle, + style: STextStyles.desktopH2(context), + ), + ), + ], + ) + : Text( + errorTitle, + style: STextStyles.pageTitleH2(context), + ), + const SizedBox( + height: 20, + ), + Padding( + padding: Util.isDesktop + ? const EdgeInsets.all(32) + : const EdgeInsets.all(20), + child: Row( + children: [ + Flexible( + child: SelectableText( + error.startsWith("Exception:") + ? error.substring(10).trim() + : error, + ), + ), + ], + ), + ), + const SizedBox( + height: 20, + ), + Padding( + padding: Util.isDesktop + ? const EdgeInsets.all(32) + : const EdgeInsets.all(20), + child: Text( + "Stop churning or try and continue?", + style: Util.isDesktop + ? STextStyles.w600_14(context) + : STextStyles.w600_14(context), + ), + ), + Padding( + padding: EdgeInsets.only( + left: Util.isDesktop ? 32 : 20, + bottom: Util.isDesktop ? 32 : 20, + right: Util.isDesktop ? 32 : 20, + ), + child: Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Stop", + onPressed: () { + ref.read(pChurningService(walletId)).stopChurning(); + Navigator.of(context).pop(); + }, + ), + ), + SizedBox( + width: Util.isDesktop ? 20 : 16, + ), + Expanded( + child: PrimaryButton( + label: "Continue", + onPressed: () { + ref.read(pChurningService(walletId)).unpause(); + Navigator.of(context).pop(); + }, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/churning/churning_progress_view.dart b/lib/pages/churning/churning_progress_view.dart new file mode 100644 index 000000000..b8b152e41 --- /dev/null +++ b/lib/pages/churning/churning_progress_view.dart @@ -0,0 +1,256 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; + +import '../../providers/churning/churning_service_provider.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/text_styles.dart'; +import '../../widgets/background.dart'; +import '../../widgets/churning/churn_progress_item.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/rounded_container.dart'; +import '../../widgets/stack_dialog.dart'; +import 'churn_error_dialog.dart'; + +class ChurningProgressView extends ConsumerStatefulWidget { + const ChurningProgressView({ + super.key, + required this.walletId, + }); + + static const routeName = "/churningProgressView"; + + final String walletId; + @override + ConsumerState createState() => + _ChurningProgressViewState(); +} + +class _ChurningProgressViewState extends ConsumerState { + Future _requestAndProcessCancel() async { + final shouldCancel = await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => StackDialog( + title: "Cancel churning?", + leftButton: SecondaryButton( + label: "No", + buttonHeight: null, + onPressed: () { + Navigator.of(context).pop(false); + }, + ), + rightButton: PrimaryButton( + label: "Yes", + buttonHeight: null, + onPressed: () { + Navigator.of(context).pop(true); + }, + ), + ), + ); + + if (shouldCancel == true && mounted) { + ref.read(pChurningService(widget.walletId)).stopChurning(); + + await WakelockPlus.disable(); + + return true; + } else { + return false; + } + } + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) ref.read(pChurningService(widget.walletId)).churn(); + }); + } + + @override + void dispose() { + WakelockPlus.disable(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final bool _succeeded = ref.watch( + pChurningService(widget.walletId).select((s) => s.done), + ); + + final int _roundsCompleted = ref.watch( + pChurningService(widget.walletId).select((s) => s.roundsCompleted), + ); + + WakelockPlus.enable(); + + ref.listen( + pChurningService(widget.walletId).select((s) => s.lastSeenError), + (p, n) { + if (!ref.read(pChurningService(widget.walletId)).ignoreErrors && + n != null) { + if (context.mounted) { + showDialog( + context: context, + builder: (context) => ChurnErrorDialog( + error: n.toString(), + walletId: widget.walletId, + ), + ); + } + } + }, + ); + + return WillPopScope( + onWillPop: () async { + return await _requestAndProcessCancel(); + }, + child: Background( + child: SafeArea( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + automaticallyImplyLeading: false, + leading: AppBarBackButton( + onPressed: () async { + if (await _requestAndProcessCancel()) { + if (context.mounted) { + Navigator.of(context).pop(); + } + } + }, + ), + title: Text( + "Churning progress", + style: STextStyles.navBarTitle(context), + ), + titleSpacing: 0, + ), + body: LayoutBuilder( + builder: (builderContext, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_roundsCompleted == 0) + RoundedContainer( + color: Theme.of(context) + .extension()! + .snackBarBackError, + child: Text( + "Do not close this window. If you exit, " + "the process will be canceled.", + style: + STextStyles.smallMed14(context).copyWith( + color: Theme.of(context) + .extension()! + .snackBarTextError, + ), + textAlign: TextAlign.center, + ), + ), + if (_roundsCompleted > 0) + RoundedContainer( + color: Theme.of(context) + .extension()! + .snackBarBackInfo, + child: Text( + "Churning rounds completed: $_roundsCompleted", + style: STextStyles.w500_14(context).copyWith( + color: Theme.of(context) + .extension()! + .snackBarTextInfo, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox( + height: 20, + ), + ProgressItem( + iconAsset: Assets.svg.peers, + label: "Waiting for balance to unlock", + status: ref.watch( + pChurningService(widget.walletId) + .select((s) => s.waitingForUnlockedBalance), + ), + ), + const SizedBox( + height: 12, + ), + ProgressItem( + iconAsset: Assets.svg.fusing, + label: "Creating churn transaction", + status: ref.watch( + pChurningService(widget.walletId) + .select((s) => s.makingChurnTransaction), + ), + ), + const SizedBox( + height: 12, + ), + ProgressItem( + iconAsset: Assets.svg.checkCircle, + label: "Complete", + status: ref.watch( + pChurningService(widget.walletId) + .select((s) => s.completedStatus), + ), + ), + const Spacer(), + const SizedBox( + height: 16, + ), + if (_succeeded) + PrimaryButton( + label: "Churn again", + onPressed: ref + .read(pChurningService(widget.walletId)) + .churn, + ), + if (_succeeded) + const SizedBox( + height: 16, + ), + SecondaryButton( + label: "Cancel", + onPressed: () async { + if (await _requestAndProcessCancel()) { + if (context.mounted) { + Navigator.of(context).pop(); + } + } + }, + ), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/churning/churning_rounds_selection_sheet.dart b/lib/pages/churning/churning_rounds_selection_sheet.dart new file mode 100644 index 000000000..de21964e8 --- /dev/null +++ b/lib/pages/churning/churning_rounds_selection_sheet.dart @@ -0,0 +1,159 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import '../../themes/stack_colors.dart'; +import '../../utilities/constants.dart'; +import '../../utilities/extensions/extensions.dart'; +import '../../utilities/text_styles.dart'; + +enum ChurnOption { + continuous, + custom; +} + +class ChurnRoundCountSelectSheet extends HookWidget { + const ChurnRoundCountSelectSheet({ + super.key, + required this.currentOption, + }); + + final ChurnOption currentOption; + + @override + Widget build(BuildContext context) { + final option = useState(currentOption); + + return WillPopScope( + onWillPop: () async { + Navigator.of(context).pop(option.value); + return false; + }, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).extension()!.popupBG, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(20), + ), + ), + child: Padding( + padding: const EdgeInsets.only( + left: 24, + right: 24, + top: 10, + bottom: 0, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + decoration: BoxDecoration( + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + width: 60, + height: 4, + ), + ), + const SizedBox( + height: 36, + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Rounds of churn", + style: STextStyles.pageTitleH2(context), + textAlign: TextAlign.left, + ), + const SizedBox( + height: 20, + ), + for (int i = 0; i < ChurnOption.values.length; i++) + Column( + children: [ + GestureDetector( + onTap: () { + option.value = ChurnOption.values[i]; + Navigator.of(context).pop(option.value); + }, + child: Container( + color: Colors.transparent, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Column( + // mainAxisAlignment: MainAxisAlignment.start, + // children: [ + SizedBox( + width: 20, + height: 20, + child: Radio( + activeColor: Theme.of(context) + .extension()! + .radioButtonIconEnabled, + value: ChurnOption.values[i], + groupValue: option.value, + onChanged: (_) { + option.value = ChurnOption.values[i]; + Navigator.of(context).pop(option.value); + }, + ), + ), + // ], + // ), + const SizedBox( + width: 12, + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + ChurnOption.values[i].name.capitalize(), + style: STextStyles.titleBold12(context), + textAlign: TextAlign.left, + ), + const SizedBox( + height: 2, + ), + Text( + ChurnOption.values[i] == + ChurnOption.continuous + ? "Keep churning until manually stopped" + : "Stop after a set number of churns", + style: STextStyles.itemSubtitle12(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textDark3, + ), + textAlign: TextAlign.left, + ), + ], + ), + ], + ), + ), + ), + const SizedBox( + height: 16, + ), + ], + ), + const SizedBox( + height: 16, + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/churning/churning_view.dart b/lib/pages/churning/churning_view.dart new file mode 100644 index 000000000..dad480778 --- /dev/null +++ b/lib/pages/churning/churning_view.dart @@ -0,0 +1,275 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../providers/churning/churning_service_provider.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/constants.dart'; +import '../../utilities/extensions/extensions.dart'; +import '../../utilities/text_styles.dart'; +import '../../widgets/background.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/custom_buttons/checkbox_text_button.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/rounded_container.dart'; +import '../../widgets/rounded_white_container.dart'; +import '../../widgets/stack_text_field.dart'; +import 'churning_progress_view.dart'; +import 'churning_rounds_selection_sheet.dart'; + +class ChurningView extends ConsumerStatefulWidget { + const ChurningView({ + super.key, + required this.walletId, + }); + + static const routeName = "/churnView"; + + final String walletId; + + @override + ConsumerState createState() => _ChurnViewState(); +} + +class _ChurnViewState extends ConsumerState { + late final TextEditingController churningRoundController; + late final FocusNode churningRoundFocusNode; + + bool _enableStartButton = false; + + ChurnOption _option = ChurnOption.continuous; + + Future _startChurn() async { + final churningService = ref.read(pChurningService(widget.walletId)); + + final int rounds = _option == ChurnOption.continuous + ? 0 + : int.parse(churningRoundController.text); + + churningService.rounds = rounds; + + await Navigator.of(context).pushNamed( + ChurningProgressView.routeName, + arguments: widget.walletId, + ); + } + + @override + void initState() { + churningRoundController = TextEditingController(); + + churningRoundFocusNode = FocusNode(); + + final rounds = ref.read(pChurningService(widget.walletId)).rounds; + + _option = rounds == 0 ? ChurnOption.continuous : ChurnOption.custom; + churningRoundController.text = rounds.toString(); + + _enableStartButton = churningRoundController.text.isNotEmpty; + + super.initState(); + } + + @override + void dispose() { + churningRoundController.dispose(); + churningRoundFocusNode.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Background( + child: SafeArea( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + automaticallyImplyLeading: false, + leading: const AppBarBackButton(), + title: Text( + "Churn", + style: STextStyles.navBarTitle(context), + ), + titleSpacing: 0, + actions: [ + AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + size: 36, + icon: SvgPicture.asset( + Assets.svg.circleQuestion, + width: 20, + height: 20, + color: Theme.of(context) + .extension()! + .topNavIconPrimary, + ), + onPressed: () async { + //' TODO show about? + }, + ), + ), + ], + ), + body: LayoutBuilder( + builder: (builderContext, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RoundedWhiteContainer( + child: Text( + "Churning helps anonymize your coins by mixing them.", + style: STextStyles.w500_12(context).copyWith( + color: Theme.of(context) + .extension()! + .textSubtitle1, + ), + ), + ), + const SizedBox( + height: 16, + ), + const SizedBox( + height: 16, + ), + Text( + "Configuration", + style: STextStyles.w500_14(context).copyWith( + color: Theme.of(context) + .extension()! + .textDark3, + ), + ), + const SizedBox( + height: 12, + ), + RoundedContainer( + onPressed: () async { + final option = + await showModalBottomSheet( + backgroundColor: Colors.transparent, + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(20), + ), + ), + builder: (_) { + return ChurnRoundCountSelectSheet( + currentOption: _option, + ); + }, + ); + if (option != null) { + setState(() { + _option = option; + }); + } + }, + color: Theme.of(context) + .extension()! + .textFieldActiveBG, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + _option.name.capitalize(), + style: STextStyles.w500_12(context), + ), + SvgPicture.asset( + Assets.svg.chevronDown, + width: 12, + color: Theme.of(context) + .extension()! + .textSubtitle1, + ), + ], + ), + ), + ), + if (_option == ChurnOption.custom) + const SizedBox( + height: 10, + ), + if (_option == ChurnOption.custom) + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + autocorrect: false, + enableSuggestions: false, + controller: churningRoundController, + focusNode: churningRoundFocusNode, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + keyboardType: TextInputType.number, + onChanged: (value) { + setState(() { + _enableStartButton = value.isNotEmpty; + }); + }, + style: STextStyles.field(context), + decoration: standardInputDecoration( + "Number of churns", + churningRoundFocusNode, + context, + ).copyWith( + labelText: "Enter number of churns..", + ), + ), + ), + const SizedBox( + height: 16, + ), + CheckboxTextButton( + label: "Pause on errors", + initialValue: !ref + .read(pChurningService(widget.walletId)) + .ignoreErrors, + onChanged: (value) { + ref + .read(pChurningService(widget.walletId)) + .ignoreErrors = !value; + }, + ), + const SizedBox( + height: 16, + ), + const Spacer(), + PrimaryButton( + label: "Start", + enabled: _enableStartButton, + onPressed: _startChurn, + ), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/pages/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart index e4ad804dc..0e6930222 100644 --- a/lib/pages/wallet_view/wallet_view.dart +++ b/lib/pages/wallet_view/wallet_view.dart @@ -50,6 +50,7 @@ import '../../wallets/crypto_currency/intermediate/frost_currency.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../../wallets/wallet/impl/bitcoin_frost_wallet.dart'; import '../../wallets/wallet/impl/firo_wallet.dart'; +import '../../wallets/wallet/intermediate/lib_monero_wallet.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/cash_fusion_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart'; @@ -66,6 +67,7 @@ import '../../widgets/loading_indicator.dart'; import '../../widgets/small_tor_icon.dart'; import '../../widgets/stack_dialog.dart'; import '../../widgets/wallet_navigation_bar/components/icons/buy_nav_icon.dart'; +import '../../widgets/wallet_navigation_bar/components/icons/churn_nav_icon.dart'; import '../../widgets/wallet_navigation_bar/components/icons/coin_control_nav_icon.dart'; import '../../widgets/wallet_navigation_bar/components/icons/exchange_nav_icon.dart'; import '../../widgets/wallet_navigation_bar/components/icons/frost_sign_nav_icon.dart'; @@ -78,6 +80,7 @@ import '../../widgets/wallet_navigation_bar/components/wallet_navigation_bar_ite import '../../widgets/wallet_navigation_bar/wallet_navigation_bar.dart'; import '../buy_view/buy_in_wallet_view.dart'; import '../cashfusion/cashfusion_view.dart'; +import '../churning/churning_view.dart'; import '../coin_control/coin_control_view.dart'; import '../exchange_view/wallet_initiated_exchange_view.dart'; import '../monkey/monkey_view.dart'; @@ -1226,6 +1229,22 @@ class _WalletViewState extends ConsumerState { ); }, ), + if (ref.watch( + pWallets.select( + (value) => + value.getWallet(widget.walletId) is LibMoneroWallet, + ), + )) + WalletNavigationBarItemData( + label: "Churn", + icon: const ChurnNavIcon(), + onTap: () { + Navigator.of(context).pushNamed( + ChurningView.routeName, + arguments: walletId, + ); + }, + ), ], ), ], diff --git a/lib/pages_desktop_specific/churning/desktop_churning_view.dart b/lib/pages_desktop_specific/churning/desktop_churning_view.dart new file mode 100644 index 000000000..fdd34b3f3 --- /dev/null +++ b/lib/pages_desktop_specific/churning/desktop_churning_view.dart @@ -0,0 +1,411 @@ +import 'dart:async'; + +import 'package:dropdown_button2/dropdown_button2.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../pages/churning/churning_rounds_selection_sheet.dart'; +import '../../providers/churning/churning_service_provider.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/constants.dart'; +import '../../utilities/extensions/extensions.dart'; +import '../../utilities/text_styles.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/custom_buttons/checkbox_text_button.dart'; +import '../../widgets/desktop/desktop_app_bar.dart'; +import '../../widgets/desktop/desktop_dialog.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../widgets/desktop/desktop_scaffold.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/rounded_white_container.dart'; +import '../../widgets/stack_text_field.dart'; +import 'sub_widgets/churning_dialog.dart'; + +class DesktopChurningView extends ConsumerStatefulWidget { + const DesktopChurningView({ + super.key, + required this.walletId, + }); + + static const String routeName = "/desktopChurningView"; + + final String walletId; + + @override + ConsumerState createState() => _DesktopChurning(); +} + +class _DesktopChurning extends ConsumerState { + late final TextEditingController churningRoundController; + late final FocusNode churningRoundFocusNode; + + bool _enableStartButton = false; + + ChurnOption _option = ChurnOption.continuous; + + Future _startChurn() async { + final churningService = ref.read(pChurningService(widget.walletId)); + + final int rounds = _option == ChurnOption.continuous + ? 0 + : int.parse(churningRoundController.text); + + churningService.rounds = rounds; + + await showDialog( + context: context, + barrierDismissible: false, + builder: (context) { + return ChurnDialogView( + walletId: widget.walletId, + ); + }, + ); + } + + @override + void initState() { + churningRoundController = TextEditingController(); + + churningRoundFocusNode = FocusNode(); + + final rounds = ref.read(pChurningService(widget.walletId)).rounds; + + _option = rounds == 0 ? ChurnOption.continuous : ChurnOption.custom; + churningRoundController.text = rounds.toString(); + + _enableStartButton = churningRoundController.text.isNotEmpty; + + super.initState(); + } + + @override + void dispose() { + churningRoundController.dispose(); + churningRoundFocusNode.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + debugPrint("BUILD: $runtimeType"); + + return DesktopScaffold( + appBar: DesktopAppBar( + background: Theme.of(context).extension()!.popupBG, + isCompactHeight: true, + useSpacers: false, + leading: Expanded( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + // const SizedBox( + // width: 32, + // ), + AppBarIconButton( + size: 32, + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, + shadows: const [], + icon: SvgPicture.asset( + Assets.svg.arrowLeft, + width: 18, + height: 18, + color: Theme.of(context) + .extension()! + .topNavIconPrimary, + ), + onPressed: Navigator.of(context).pop, + ), + const SizedBox( + width: 15, + ), + SvgPicture.asset( + Assets.svg.churn, + width: 32, + height: 32, + ), + const SizedBox( + width: 12, + ), + Text( + "Churning", + style: STextStyles.desktopH3(context), + ), + ], + ), + MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () {}, + child: Row( + children: [ + SvgPicture.asset( + Assets.svg.circleQuestion, + color: Theme.of(context) + .extension()! + .radioButtonIconBorder, + ), + const SizedBox( + width: 8, + ), + RichText( + text: TextSpan( + text: "What is churning?", + style: STextStyles.richLink(context).copyWith( + fontSize: 16, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return DesktopDialog( + maxWidth: 580, + maxHeight: double.infinity, + child: Padding( + padding: const EdgeInsets.only( + top: 10, + left: 20, + bottom: 20, + right: 10, + ), + child: Column( + children: [ + Row( + mainAxisAlignment: + MainAxisAlignment + .spaceBetween, + children: [ + Text( + "What is churning?", + style: STextStyles.desktopH2( + context, + ), + ), + DesktopDialogCloseButton( + onPressedOverride: () => + Navigator.of(context) + .pop(true), + ), + ], + ), + const SizedBox( + height: 16, + ), + Text( + "Churning info text", + style: + STextStyles.desktopTextMedium( + context, + ).copyWith( + color: Theme.of(context) + .extension()! + .textDark3, + ), + ), + ], + ), + ), + ); + }, + ); + }, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + body: Row( + children: [ + Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 460, + child: RoundedWhiteContainer( + child: Row( + children: [ + Text( + "Churning helps anonymize your coins by mixing them.", + style: + STextStyles.desktopTextExtraExtraSmall(context), + ), + ], + ), + ), + ), + const SizedBox( + height: 24, + ), + SizedBox( + width: 460, + child: RoundedWhiteContainer( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Configuration", + style: + STextStyles.desktopTextExtraExtraSmall(context), + ), + const SizedBox( + height: 10, + ), + DropdownButtonHideUnderline( + child: DropdownButton2( + value: _option, + items: [ + ...ChurnOption.values.map( + (e) => DropdownMenuItem( + value: e, + child: Text( + e.name.capitalize(), + style: STextStyles.smallMed14(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textDark, + ), + ), + ), + ), + ], + onChanged: (value) { + if (value is ChurnOption) { + setState(() { + _option = value; + }); + } + }, + isExpanded: true, + iconStyleData: IconStyleData( + icon: SvgPicture.asset( + Assets.svg.chevronDown, + width: 12, + height: 6, + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconRight, + ), + ), + dropdownStyleData: DropdownStyleData( + offset: const Offset(0, -10), + elevation: 0, + decoration: BoxDecoration( + color: Theme.of(context) + .extension()! + .textFieldActiveBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + ), + menuItemStyleData: const MenuItemStyleData( + padding: EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + ), + ), + ), + if (_option == ChurnOption.custom) + const SizedBox( + height: 10, + ), + if (_option == ChurnOption.custom) + SizedBox( + width: 460, + child: RoundedWhiteContainer( + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + autocorrect: false, + enableSuggestions: false, + controller: churningRoundController, + focusNode: churningRoundFocusNode, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + onChanged: (value) { + setState(() { + _enableStartButton = value.isNotEmpty; + }); + }, + style: STextStyles.field(context), + decoration: standardInputDecoration( + "Number of churns", + churningRoundFocusNode, + context, + desktopMed: true, + ).copyWith( + labelText: "Enter number of churns..", + ), + ), + ), + ], + ), + ), + ), + const SizedBox( + height: 20, + ), + CheckboxTextButton( + label: "Pause on errors", + initialValue: !ref + .read(pChurningService(widget.walletId)) + .ignoreErrors, + onChanged: (value) { + ref + .read(pChurningService(widget.walletId)) + .ignoreErrors = !value; + }, + ), + const SizedBox( + height: 20, + ), + PrimaryButton( + label: "Start", + enabled: _enableStartButton, + buttonHeight: ButtonHeight.l, + onPressed: _startChurn, + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages_desktop_specific/churning/sub_widgets/churning_dialog.dart b/lib/pages_desktop_specific/churning/sub_widgets/churning_dialog.dart new file mode 100644 index 000000000..3c7d96b1f --- /dev/null +++ b/lib/pages_desktop_specific/churning/sub_widgets/churning_dialog.dart @@ -0,0 +1,320 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; + +import '../../../pages/churning/churn_error_dialog.dart'; +import '../../../providers/churning/churning_service_provider.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/assets.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../widgets/churning/churn_progress_item.dart'; +import '../../../widgets/desktop/desktop_dialog.dart'; +import '../../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../../widgets/desktop/primary_button.dart'; +import '../../../widgets/desktop/secondary_button.dart'; +import '../../../widgets/rounded_container.dart'; +import '../../../widgets/rounded_white_container.dart'; + +class ChurnDialogView extends ConsumerStatefulWidget { + const ChurnDialogView({ + super.key, + required this.walletId, + }); + + final String walletId; + + @override + ConsumerState createState() => _ChurnDialogViewState(); +} + +class _ChurnDialogViewState extends ConsumerState { + Future _requestAndProcessCancel() async { + final bool? shouldCancel = await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => DesktopDialog( + maxWidth: 580, + maxHeight: double.infinity, + child: Padding( + padding: const EdgeInsets.only( + left: 32, + right: 0, + top: 0, + bottom: 32, + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Cancel churning?", + style: STextStyles.desktopH3(context), + ), + DesktopDialogCloseButton( + onPressedOverride: () => Navigator.of(context).pop(false), + ), + ], + ), + Padding( + padding: const EdgeInsets.only( + left: 0, + right: 32, + top: 0, + bottom: 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Do you really want to cancel the churning process?", + style: STextStyles.smallMed14(context), + textAlign: TextAlign.left, + ), + const SizedBox(height: 40), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "No", + buttonHeight: ButtonHeight.l, + onPressed: () { + Navigator.of(context).pop(false); + }, + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Yes", + buttonHeight: ButtonHeight.l, + onPressed: () { + Navigator.of(context).pop(true); + }, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ); + + if (shouldCancel == true && mounted) { + ref.read(pChurningService(widget.walletId)).stopChurning(); + + await WakelockPlus.disable(); + + return true; + } else { + return false; + } + } + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) ref.read(pChurningService(widget.walletId)).churn(); + }); + } + + @override + dispose() { + WakelockPlus.disable(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final bool _succeeded = ref.watch( + pChurningService(widget.walletId).select((s) => s.done), + ); + + final int _roundsCompleted = ref.watch( + pChurningService(widget.walletId).select((s) => s.roundsCompleted), + ); + + if (!Platform.isLinux) { + WakelockPlus.enable(); + } + + ref.listen( + pChurningService(widget.walletId).select((s) => s.lastSeenError), + (p, n) { + if (!ref.read(pChurningService(widget.walletId)).ignoreErrors && + n != null) { + if (context.mounted) { + showDialog( + context: context, + builder: (context) => ChurnErrorDialog( + error: n.toString(), + walletId: widget.walletId, + ), + ); + } + } + }, + ); + + return DesktopDialog( + maxHeight: 600, + child: SingleChildScrollView( + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Churn progress", + style: STextStyles.desktopH2(context), + ), + ), + DesktopDialogCloseButton( + onPressedOverride: () async { + if (_succeeded) { + Navigator.of(context).pop(); + } else { + if (await _requestAndProcessCancel()) { + if (context.mounted) { + Navigator.of(context).pop(); + } + } + } + }, + ), + ], + ), + Padding( + padding: const EdgeInsets.only( + top: 20, + left: 32, + right: 32, + bottom: 32, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _roundsCompleted > 0 + ? RoundedWhiteContainer( + child: Text( + "Churn rounds completed: $_roundsCompleted", + style: STextStyles.w500_14(context).copyWith( + color: Theme.of(context) + .extension()! + .textSubtitle1, + ), + textAlign: TextAlign.center, + ), + ) + : RoundedContainer( + color: Theme.of(context) + .extension()! + .snackBarBackError, + child: Text( + "Do not close this window. If you exit, " + "the process will be canceled.", + style: STextStyles.smallMed14(context).copyWith( + color: Theme.of(context) + .extension()! + .snackBarTextError, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox( + height: 20, + ), + ProgressItem( + iconAsset: Assets.svg.peers, + label: "Waiting for balance to unlock", + status: ref.watch( + pChurningService(widget.walletId) + .select((s) => s.waitingForUnlockedBalance), + ), + ), + const SizedBox( + height: 12, + ), + ProgressItem( + iconAsset: Assets.svg.fusing, + label: "Creating churn transaction", + status: ref.watch( + pChurningService(widget.walletId) + .select((s) => s.makingChurnTransaction), + ), + ), + const SizedBox( + height: 12, + ), + ProgressItem( + iconAsset: Assets.svg.checkCircle, + label: "Complete", + status: ref.watch( + pChurningService(widget.walletId) + .select((s) => s.completedStatus), + ), + ), + const SizedBox( + height: 12, + ), + Row( + children: [ + if (_succeeded) + Expanded( + child: PrimaryButton( + buttonHeight: ButtonHeight.m, + label: "Churn again", + onPressed: ref + .read(pChurningService(widget.walletId)) + .churn, + ), + ), + if (_succeeded) + const SizedBox( + width: 16, + ), + if (!_succeeded) const Spacer(), + if (!_succeeded) + const SizedBox( + width: 16, + ), + Expanded( + child: SecondaryButton( + buttonHeight: ButtonHeight.m, + enabled: true, + label: _succeeded ? "Done" : "Cancel", + onPressed: () async { + if (_succeeded) { + Navigator.of(context).pop(); + } else { + if (await _requestAndProcessCancel()) { + if (context.mounted) { + Navigator.of(context).pop(); + } + } + } + }, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart index 4d9eaf026..bcc5a1146 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart @@ -45,6 +45,7 @@ import '../../../../widgets/desktop/primary_button.dart'; import '../../../../widgets/desktop/secondary_button.dart'; import '../../../../widgets/loading_indicator.dart'; import '../../../cashfusion/desktop_cashfusion_view.dart'; +import '../../../churning/desktop_churning_view.dart'; import '../../../coin_control/desktop_coin_control_view.dart'; import '../../../desktop_menu.dart'; import '../../../ordinals/desktop_ordinals_view.dart'; @@ -92,6 +93,7 @@ class _DesktopWalletFeaturesState extends ConsumerState { onOrdinalsPressed: _onOrdinalsPressed, onMonkeyPressed: _onMonkeyPressed, onFusionPressed: _onFusionPressed, + onChurnPressed: _onChurnPressed, ), ); } @@ -348,6 +350,15 @@ class _DesktopWalletFeaturesState extends ConsumerState { ); } + void _onChurnPressed() { + Navigator.of(context, rootNavigator: true).pop(); + + Navigator.of(context).pushNamed( + DesktopChurningView.routeName, + arguments: widget.walletId, + ); + } + @override Widget build(BuildContext context) { final wallet = ref.watch(pWallets).getWallet(widget.walletId); diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart index f849b7b09..d77c989ad 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart @@ -23,6 +23,7 @@ import '../../../../../utilities/text_styles.dart'; import '../../../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../../../wallets/isar/models/wallet_info.dart'; import '../../../../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../../../../wallets/wallet/intermediate/lib_monero_wallet.dart'; import '../../../../../wallets/wallet/wallet_mixin_interfaces/cash_fusion_interface.dart'; import '../../../../../wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart'; import '../../../../../wallets/wallet/wallet_mixin_interfaces/lelantus_interface.dart'; @@ -48,6 +49,7 @@ class MoreFeaturesDialog extends ConsumerStatefulWidget { required this.onOrdinalsPressed, required this.onMonkeyPressed, required this.onFusionPressed, + required this.onChurnPressed, }); final String walletId; @@ -58,6 +60,7 @@ class MoreFeaturesDialog extends ConsumerStatefulWidget { final VoidCallback? onOrdinalsPressed; final VoidCallback? onMonkeyPressed; final VoidCallback? onFusionPressed; + final VoidCallback? onChurnPressed; @override ConsumerState createState() => _MoreFeaturesDialogState(); @@ -304,6 +307,13 @@ class _MoreFeaturesDialogState extends ConsumerState { iconAsset: Assets.svg.cashFusion, onPressed: () async => widget.onFusionPressed?.call(), ), + if (wallet is LibMoneroWallet) + _MoreFeaturesItem( + label: "Churn", + detail: "Churning", + iconAsset: Assets.svg.churn, + onPressed: () async => widget.onChurnPressed?.call(), + ), if (wallet is SparkInterface) _MoreFeaturesClearSparkCacheItem( cryptoCurrency: wallet.cryptoCurrency, diff --git a/lib/providers/churning/churning_service_provider.dart b/lib/providers/churning/churning_service_provider.dart new file mode 100644 index 000000000..642d1103f --- /dev/null +++ b/lib/providers/churning/churning_service_provider.dart @@ -0,0 +1,12 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../services/churning_service.dart'; +import '../../wallets/wallet/intermediate/lib_monero_wallet.dart'; +import '../global/wallets_provider.dart'; + +final pChurningService = ChangeNotifierProvider.family( + (ref, walletId) { + final wallet = ref.watch(pWallets.select((s) => s.getWallet(walletId))); + return ChurningService(wallet: wallet as LibMoneroWallet); + }, +); diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 6c58b600e..f18184a0f 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -52,6 +52,8 @@ import 'pages/buy_view/buy_quote_preview.dart'; import 'pages/buy_view/buy_view.dart'; import 'pages/cashfusion/cashfusion_view.dart'; import 'pages/cashfusion/fusion_progress_view.dart'; +import 'pages/churning/churning_progress_view.dart'; +import 'pages/churning/churning_view.dart'; import 'pages/coin_control/coin_control_view.dart'; import 'pages/coin_control/utxo_details_view.dart'; import 'pages/exchange_view/choose_from_stack_view.dart'; @@ -155,6 +157,7 @@ import 'pages/wallets_view/wallets_view.dart'; import 'pages_desktop_specific/address_book_view/desktop_address_book.dart'; import 'pages_desktop_specific/addresses/desktop_wallet_addresses_view.dart'; import 'pages_desktop_specific/cashfusion/desktop_cashfusion_view.dart'; +import 'pages_desktop_specific/churning/desktop_churning_view.dart'; import 'pages_desktop_specific/coin_control/desktop_coin_control_view.dart'; // import 'package:stackwallet/pages_desktop_specific/desktop_exchange/desktop_all_buys_view.dart'; import 'pages_desktop_specific/desktop_buy/desktop_buy_view.dart'; @@ -779,6 +782,34 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case ChurningView.routeName: + if (args is String) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ChurningView( + walletId: args, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case ChurningProgressView.routeName: + if (args is String) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ChurningProgressView( + walletId: args, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + case DesktopCashFusionView.routeName: if (args is String) { return getRoute( @@ -793,6 +824,20 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case DesktopChurningView.routeName: + if (args is String) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => DesktopChurningView( + walletId: args, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + case GlobalSettingsView.routeName: return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, diff --git a/lib/services/churning_service.dart b/lib/services/churning_service.dart new file mode 100644 index 000000000..fb3bca6e1 --- /dev/null +++ b/lib/services/churning_service.dart @@ -0,0 +1,154 @@ +import 'dart:async'; + +import 'package:cs_monero/cs_monero.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:mutex/mutex.dart'; + +import '../wallets/wallet/intermediate/lib_monero_wallet.dart'; + +enum ChurnStatus { + waiting, + running, + failed, + success; +} + +class ChurningService extends ChangeNotifier { + // stack only uses account 0 at this point in time + static const kAccount = 0; + + ChurningService({required this.wallet}); + + final LibMoneroWallet wallet; + Wallet get csWallet => wallet.libMoneroWallet!; + + int rounds = 1; // default + bool ignoreErrors = false; // default + + bool _running = false; + + ChurnStatus waitingForUnlockedBalance = ChurnStatus.waiting; + ChurnStatus makingChurnTransaction = ChurnStatus.waiting; + ChurnStatus completedStatus = ChurnStatus.waiting; + int roundsCompleted = 0; + bool done = false; + Object? lastSeenError; + + bool _canChurn() { + if (csWallet.getUnlockedBalance(accountIndex: kAccount) > BigInt.zero) { + return true; + } else { + return false; + } + } + + final _pause = Mutex(); + bool get isPaused => _pause.isLocked; + void unpause() { + if (_pause.isLocked) _pause.release(); + } + + Future churn() async { + if (rounds < 0 || _running) { + // TODO: error? + return; + } + + _running = true; + waitingForUnlockedBalance = ChurnStatus.running; + makingChurnTransaction = ChurnStatus.waiting; + completedStatus = ChurnStatus.waiting; + roundsCompleted = 0; + done = false; + lastSeenError = null; + notifyListeners(); + + final roundsToDo = rounds; + final continuous = rounds == 0; + + bool complete() => !continuous && roundsCompleted >= roundsToDo; + + while (!complete() && _running) { + if (_canChurn()) { + waitingForUnlockedBalance = ChurnStatus.success; + makingChurnTransaction = ChurnStatus.running; + notifyListeners(); + + try { + Logging.log?.i("Doing churn #${roundsCompleted + 1}"); + await _churnTxSimple(); + waitingForUnlockedBalance = ChurnStatus.success; + makingChurnTransaction = ChurnStatus.success; + roundsCompleted++; + notifyListeners(); + } catch (e, s) { + Logging.log?.e( + "Churning round #${roundsCompleted + 1} failed", + error: e, + stackTrace: s, + ); + lastSeenError = e; + makingChurnTransaction = ChurnStatus.failed; + notifyListeners(); + if (!ignoreErrors) { + await _pause.acquire(); + await _pause.protect(() async {}); + + if (!_running) { + completedStatus = ChurnStatus.failed; + // exit if stop option chosen on error + return; + } + } + } + } else { + Logging.log?.i("Can't churn yet, waiting..."); + } + + if (!complete() && _running) { + waitingForUnlockedBalance = ChurnStatus.running; + makingChurnTransaction = ChurnStatus.waiting; + completedStatus = ChurnStatus.waiting; + notifyListeners(); + // sleep + await Future.delayed(const Duration(seconds: 30)); + } + } + + waitingForUnlockedBalance = ChurnStatus.success; + makingChurnTransaction = ChurnStatus.success; + completedStatus = ChurnStatus.success; + done = true; + _running = false; + notifyListeners(); + Logging.log?.i("Churning complete"); + } + + void stopChurning() { + done = true; + _running = false; + notifyListeners(); + unpause(); + } + + Future _churnTxSimple({ + final TransactionPriority priority = TransactionPriority.normal, + }) async { + final address = csWallet.getAddress( + accountIndex: kAccount, + addressIndex: 0, + ); + + final pending = await csWallet.createTx( + output: Recipient( + address: address.value, + amount: BigInt.zero, // Doesn't matter if `sweep` is true + ), + priority: priority, + accountIndex: kAccount, + sweep: true, + ); + + await csWallet.commitTx(pending); + } +} diff --git a/lib/utilities/assets.dart b/lib/utilities/assets.dart index c8bfaf1fb..e0245131a 100644 --- a/lib/utilities/assets.dart +++ b/lib/utilities/assets.dart @@ -205,6 +205,7 @@ class _SVG { String get robotHead => "assets/svg/robot-head.svg"; String get whirlPool => "assets/svg/whirlpool.svg"; String get cashFusion => "assets/svg/cashfusion-icon.svg"; + String get churn => "assets/svg/churn.svg"; String get fingerprint => "assets/svg/fingerprint.svg"; String get faceId => "assets/svg/faceid.svg"; String get tokens => "assets/svg/tokens.svg"; diff --git a/lib/widgets/churning/churn_progress_item.dart b/lib/widgets/churning/churn_progress_item.dart new file mode 100644 index 000000000..8e989bc08 --- /dev/null +++ b/lib/widgets/churning/churn_progress_item.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../../pages/settings_views/global_settings_view/stack_backup_views/sub_widgets/restoring_item_card.dart'; +import '../../services/churning_service.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../conditional_parent.dart'; +import '../rounded_container.dart'; + +class ProgressItem extends StatelessWidget { + const ProgressItem({ + super.key, + required this.iconAsset, + required this.label, + required this.status, + this.error, + }); + + final String iconAsset; + final String label; + final ChurnStatus status; + final Object? error; + + Widget _getIconForState(ChurnStatus status, BuildContext context) { + switch (status) { + case ChurnStatus.waiting: + return SvgPicture.asset( + Assets.svg.loader, + color: + Theme.of(context).extension()!.buttonBackSecondary, + ); + case ChurnStatus.running: + return SvgPicture.asset( + Assets.svg.loader, + color: Theme.of(context).extension()!.accentColorGreen, + ); + case ChurnStatus.success: + return SvgPicture.asset( + Assets.svg.checkCircle, + color: Theme.of(context).extension()!.accentColorGreen, + ); + case ChurnStatus.failed: + return SvgPicture.asset( + Assets.svg.circleAlert, + color: Theme.of(context).extension()!.textError, + ); + } + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => RoundedContainer( + padding: EdgeInsets.zero, + color: Theme.of(context).extension()!.popupBG, + borderColor: Theme.of(context).extension()!.background, + child: child, + ), + child: RestoringItemCard( + left: SizedBox( + width: 32, + height: 32, + child: RoundedContainer( + padding: const EdgeInsets.all(0), + color: + Theme.of(context).extension()!.buttonBackSecondary, + child: Center( + child: SvgPicture.asset( + iconAsset, + width: 18, + height: 18, + color: Theme.of(context).extension()!.textDark, + ), + ), + ), + ), + right: SizedBox( + width: 20, + height: 20, + child: _getIconForState(status, context), + ), + title: label, + subTitle: error != null + ? Text( + error!.toString(), + style: STextStyles.w500_12(context).copyWith( + color: Theme.of(context).extension()!.textError, + ), + ) + : null, + ), + ); + } +} diff --git a/lib/widgets/custom_buttons/checkbox_text_button.dart b/lib/widgets/custom_buttons/checkbox_text_button.dart index 29e6c5ada..6a451335b 100644 --- a/lib/widgets/custom_buttons/checkbox_text_button.dart +++ b/lib/widgets/custom_buttons/checkbox_text_button.dart @@ -1,18 +1,31 @@ import 'package:flutter/material.dart'; + import '../../utilities/text_styles.dart'; class CheckboxTextButton extends StatefulWidget { - const CheckboxTextButton({super.key, required this.label, this.onChanged}); + const CheckboxTextButton({ + super.key, + required this.label, + this.onChanged, + this.initialValue = false, + }); final String label; final void Function(bool)? onChanged; + final bool initialValue; @override State createState() => _CheckboxTextButtonState(); } class _CheckboxTextButtonState extends State { - bool _value = false; + late bool _value; + + @override + void initState() { + super.initState(); + _value = widget.initialValue; + } @override Widget build(BuildContext context) { diff --git a/lib/widgets/wallet_navigation_bar/components/icons/churn_nav_icon.dart b/lib/widgets/wallet_navigation_bar/components/icons/churn_nav_icon.dart new file mode 100644 index 000000000..af404f5c2 --- /dev/null +++ b/lib/widgets/wallet_navigation_bar/components/icons/churn_nav_icon.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../../../../themes/stack_colors.dart'; +import '../../../../utilities/assets.dart'; + +class ChurnNavIcon extends StatelessWidget { + const ChurnNavIcon({super.key}); + + @override + Widget build(BuildContext context) { + return SvgPicture.asset( + Assets.svg.churn, + height: 20, + width: 20, + color: Theme.of(context).extension()!.bottomNavIconIcon, + ); + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e510c39b0..88c196c5e 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,7 +6,7 @@ #include "generated_plugin_registrant.h" -``#include +#include #include #include #include