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