feat: rough "churning" for xmr/wow

This commit is contained in:
julian 2024-11-07 16:09:54 -06:00 committed by julian-CStack
parent a03b0ec2aa
commit 9cde0a1f65
20 changed files with 1942 additions and 3 deletions

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.77444 13.4823C9.60687 13.4092 9.43072 13.3749 9.25027 13.3749H3.75081C2.99076 13.3749 2.37594 13.9897 2.37594 14.7497C2.37594 15.5098 2.99076 16.1246 3.75081 16.1246H5.93126L1.40279 20.6531C0.865736 21.1901 0.865736 22.0602 1.40279 22.5972C1.93985 23.1343 2.80988 23.1343 3.34694 22.5972L7.8754 18.0709V20.2492C7.8754 21.0092 8.49023 21.6241 9.25027 21.6241C10.0103 21.6241 10.6251 21.0092 10.6251 20.2492V14.7497C10.6251 14.5708 10.5887 14.3926 10.5192 14.2247C10.3802 13.8861 10.1139 13.6198 9.77444 13.4823ZM14.2256 10.5177C14.3931 10.5908 14.5693 10.6251 14.7497 10.6251H20.2492C21.0092 10.6251 21.6241 10.0103 21.6241 9.25027C21.6241 8.49023 21.0092 7.8754 20.2492 7.8754H18.0687L22.5972 3.34694C23.1343 2.80988 23.1343 1.93985 22.5972 1.40279C22.0606 0.866166 21.1905 0.865306 20.6531 1.40279L16.1246 5.93341V3.75081C16.1246 2.99076 15.5098 2.37594 14.7497 2.37594C13.9897 2.37594 13.3749 2.99076 13.3749 3.75081V9.25027C13.3749 9.42917 13.4113 9.60739 13.4807 9.7753C13.6198 10.1139 13.8861 10.3802 14.2256 10.5177ZM9.25027 2.37594C8.4898 2.37594 7.8754 2.99076 7.8754 3.75081V5.93126L3.34823 1.40387C2.81117 0.86681 1.94114 0.86681 1.40408 1.40387C0.867025 1.94092 0.867025 2.81096 1.40408 3.34801L5.93341 7.8754H3.75081C2.99076 7.8754 2.37594 8.4898 2.37594 9.25027C2.37594 10.0107 2.99076 10.6251 3.75081 10.6251H9.25027C9.42917 10.6251 9.60739 10.5887 9.7753 10.5192C10.1139 10.3802 10.3802 10.1139 10.5177 9.77444C10.5908 9.60687 10.6251 9.43072 10.6251 9.25027V3.75081C10.6251 2.99076 10.0107 2.37594 9.25027 2.37594ZM18.0709 16.1246H20.2492C21.0092 16.1246 21.6241 15.5098 21.6241 14.7497C21.6241 13.9897 21.0092 13.3749 20.2492 13.3749H14.7497C14.5708 13.3749 14.3926 13.4113 14.2247 13.4806C13.8879 13.6199 13.6198 13.8879 13.4806 14.2247C13.4092 14.3931 13.3749 14.5693 13.3749 14.7497V20.2492C13.3749 21.0092 13.9897 21.6241 14.7497 21.6241C15.5098 21.6241 16.1246 21.0092 16.1246 20.2492V18.0687L20.6531 22.5972C21.1901 23.1343 22.0602 23.1343 22.5972 22.5972C23.1338 22.0606 23.1347 21.1905 22.5972 20.6531L18.0709 16.1246Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.77444 13.4823C9.60687 13.4092 9.43072 13.3749 9.25027 13.3749H3.75081C2.99076 13.3749 2.37594 13.9897 2.37594 14.7497C2.37594 15.5098 2.99076 16.1246 3.75081 16.1246H5.93126L1.40279 20.6531C0.865736 21.1901 0.865736 22.0602 1.40279 22.5972C1.93985 23.1343 2.80988 23.1343 3.34694 22.5972L7.8754 18.0709V20.2492C7.8754 21.0092 8.49023 21.6241 9.25027 21.6241C10.0103 21.6241 10.6251 21.0092 10.6251 20.2492V14.7497C10.6251 14.5708 10.5887 14.3926 10.5192 14.2247C10.3802 13.8861 10.1139 13.6198 9.77444 13.4823ZM14.2256 10.5177C14.3931 10.5908 14.5693 10.6251 14.7497 10.6251H20.2492C21.0092 10.6251 21.6241 10.0103 21.6241 9.25027C21.6241 8.49023 21.0092 7.8754 20.2492 7.8754H18.0687L22.5972 3.34694C23.1343 2.80988 23.1343 1.93985 22.5972 1.40279C22.0606 0.866166 21.1905 0.865306 20.6531 1.40279L16.1246 5.93341V3.75081C16.1246 2.99076 15.5098 2.37594 14.7497 2.37594C13.9897 2.37594 13.3749 2.99076 13.3749 3.75081V9.25027C13.3749 9.42917 13.4113 9.60739 13.4807 9.7753C13.6198 10.1139 13.8861 10.3802 14.2256 10.5177ZM9.25027 2.37594C8.4898 2.37594 7.8754 2.99076 7.8754 3.75081V5.93126L3.34823 1.40387C2.81117 0.86681 1.94114 0.86681 1.40408 1.40387C0.867025 1.94092 0.867025 2.81096 1.40408 3.34801L5.93341 7.8754H3.75081C2.99076 7.8754 2.37594 8.4898 2.37594 9.25027C2.37594 10.0107 2.99076 10.6251 3.75081 10.6251H9.25027C9.42917 10.6251 9.60739 10.5887 9.7753 10.5192C10.1139 10.3802 10.3802 10.1139 10.5177 9.77444C10.5908 9.60687 10.6251 9.43072 10.6251 9.25027V3.75081C10.6251 2.99076 10.0107 2.37594 9.25027 2.37594ZM18.0709 16.1246H20.2492C21.0092 16.1246 21.6241 15.5098 21.6241 14.7497C21.6241 13.9897 21.0092 13.3749 20.2492 13.3749H14.7497C14.5708 13.3749 14.3926 13.4113 14.2247 13.4806C13.8879 13.6199 13.6198 13.8879 13.4806 14.2247C13.4092 14.3931 13.3749 14.5693 13.3749 14.7497V20.2492C13.3749 21.0092 13.9897 21.6241 14.7497 21.6241C15.5098 21.6241 16.1246 21.0092 16.1246 20.2492V18.0687L20.6531 22.5972C21.1901 23.1343 22.0602 23.1343 22.5972 22.5972C23.1338 22.0606 23.1347 21.1905 22.5972 20.6531L18.0709 16.1246Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.77444 13.4823C9.60687 13.4092 9.43072 13.3749 9.25027 13.3749H3.75081C2.99076 13.3749 2.37594 13.9897 2.37594 14.7497C2.37594 15.5098 2.99076 16.1246 3.75081 16.1246H5.93126L1.40279 20.6531C0.865736 21.1901 0.865736 22.0602 1.40279 22.5972C1.93985 23.1343 2.80988 23.1343 3.34694 22.5972L7.8754 18.0709V20.2492C7.8754 21.0092 8.49023 21.6241 9.25027 21.6241C10.0103 21.6241 10.6251 21.0092 10.6251 20.2492V14.7497C10.6251 14.5708 10.5887 14.3926 10.5192 14.2247C10.3802 13.8861 10.1139 13.6198 9.77444 13.4823ZM14.2256 10.5177C14.3931 10.5908 14.5693 10.6251 14.7497 10.6251H20.2492C21.0092 10.6251 21.6241 10.0103 21.6241 9.25027C21.6241 8.49023 21.0092 7.8754 20.2492 7.8754H18.0687L22.5972 3.34694C23.1343 2.80988 23.1343 1.93985 22.5972 1.40279C22.0606 0.866166 21.1905 0.865306 20.6531 1.40279L16.1246 5.93341V3.75081C16.1246 2.99076 15.5098 2.37594 14.7497 2.37594C13.9897 2.37594 13.3749 2.99076 13.3749 3.75081V9.25027C13.3749 9.42917 13.4113 9.60739 13.4807 9.7753C13.6198 10.1139 13.8861 10.3802 14.2256 10.5177ZM9.25027 2.37594C8.4898 2.37594 7.8754 2.99076 7.8754 3.75081V5.93126L3.34823 1.40387C2.81117 0.86681 1.94114 0.86681 1.40408 1.40387C0.867025 1.94092 0.867025 2.81096 1.40408 3.34801L5.93341 7.8754H3.75081C2.99076 7.8754 2.37594 8.4898 2.37594 9.25027C2.37594 10.0107 2.99076 10.6251 3.75081 10.6251H9.25027C9.42917 10.6251 9.60739 10.5887 9.7753 10.5192C10.1139 10.3802 10.3802 10.1139 10.5177 9.77444C10.5908 9.60687 10.6251 9.43072 10.6251 9.25027V3.75081C10.6251 2.99076 10.0107 2.37594 9.25027 2.37594ZM18.0709 16.1246H20.2492C21.0092 16.1246 21.6241 15.5098 21.6241 14.7497C21.6241 13.9897 21.0092 13.3749 20.2492 13.3749H14.7497C14.5708 13.3749 14.3926 13.4113 14.2247 13.4806C13.8879 13.6199 13.6198 13.8879 13.4806 14.2247C13.4092 14.3931 13.3749 14.5693 13.3749 14.7497V20.2492C13.3749 21.0092 13.9897 21.6241 14.7497 21.6241C15.5098 21.6241 16.1246 21.0092 16.1246 20.2492V18.0687L20.6531 22.5972C21.1901 23.1343 22.0602 23.1343 22.5972 22.5972C23.1338 22.0606 23.1347 21.1905 22.5972 20.6531L18.0709 16.1246Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -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();
},
),
),
],
),
),
],
),
),
);
}
}

View file

@ -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<ChurningProgressView> createState() =>
_ChurningProgressViewState();
}
class _ChurningProgressViewState extends ConsumerState<ChurningProgressView> {
Future<bool> _requestAndProcessCancel() async {
final shouldCancel = await showDialog<bool?>(
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<void>(
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<StackColors>()!.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<StackColors>()!
.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<StackColors>()!
.snackBarTextError,
),
textAlign: TextAlign.center,
),
),
if (_roundsCompleted > 0)
RoundedContainer(
color: Theme.of(context)
.extension<StackColors>()!
.snackBarBackInfo,
child: Text(
"Churning rounds completed: $_roundsCompleted",
style: STextStyles.w500_14(context).copyWith(
color: Theme.of(context)
.extension<StackColors>()!
.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();
}
}
},
),
],
),
),
),
),
);
},
),
),
),
),
);
}
}

View file

@ -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<StackColors>()!.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<StackColors>()!
.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<StackColors>()!
.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<StackColors>()!
.textDark3,
),
textAlign: TextAlign.left,
),
],
),
],
),
),
),
const SizedBox(
height: 16,
),
],
),
const SizedBox(
height: 16,
),
],
),
],
),
),
),
);
}
}

View file

@ -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<ChurningView> createState() => _ChurnViewState();
}
class _ChurnViewState extends ConsumerState<ChurningView> {
late final TextEditingController churningRoundController;
late final FocusNode churningRoundFocusNode;
bool _enableStartButton = false;
ChurnOption _option = ChurnOption.continuous;
Future<void> _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<StackColors>()!.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<StackColors>()!
.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<StackColors>()!
.textSubtitle1,
),
),
),
const SizedBox(
height: 16,
),
const SizedBox(
height: 16,
),
Text(
"Configuration",
style: STextStyles.w500_14(context).copyWith(
color: Theme.of(context)
.extension<StackColors>()!
.textDark3,
),
),
const SizedBox(
height: 12,
),
RoundedContainer(
onPressed: () async {
final option =
await showModalBottomSheet<ChurnOption?>(
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<StackColors>()!
.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<StackColors>()!
.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,
),
],
),
),
),
),
);
},
),
),
),
);
}
}

View file

@ -50,6 +50,7 @@ import '../../wallets/crypto_currency/intermediate/frost_currency.dart';
import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart';
import '../../wallets/wallet/impl/bitcoin_frost_wallet.dart'; import '../../wallets/wallet/impl/bitcoin_frost_wallet.dart';
import '../../wallets/wallet/impl/firo_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/cash_fusion_interface.dart';
import '../../wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart';
import '../../wallets/wallet/wallet_mixin_interfaces/ordinals_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/small_tor_icon.dart';
import '../../widgets/stack_dialog.dart'; import '../../widgets/stack_dialog.dart';
import '../../widgets/wallet_navigation_bar/components/icons/buy_nav_icon.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/coin_control_nav_icon.dart';
import '../../widgets/wallet_navigation_bar/components/icons/exchange_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'; 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 '../../widgets/wallet_navigation_bar/wallet_navigation_bar.dart';
import '../buy_view/buy_in_wallet_view.dart'; import '../buy_view/buy_in_wallet_view.dart';
import '../cashfusion/cashfusion_view.dart'; import '../cashfusion/cashfusion_view.dart';
import '../churning/churning_view.dart';
import '../coin_control/coin_control_view.dart'; import '../coin_control/coin_control_view.dart';
import '../exchange_view/wallet_initiated_exchange_view.dart'; import '../exchange_view/wallet_initiated_exchange_view.dart';
import '../monkey/monkey_view.dart'; import '../monkey/monkey_view.dart';
@ -1226,6 +1229,22 @@ class _WalletViewState extends ConsumerState<WalletView> {
); );
}, },
), ),
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,
);
},
),
], ],
), ),
], ],

View file

@ -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<DesktopChurningView> createState() => _DesktopChurning();
}
class _DesktopChurning extends ConsumerState<DesktopChurningView> {
late final TextEditingController churningRoundController;
late final FocusNode churningRoundFocusNode;
bool _enableStartButton = false;
ChurnOption _option = ChurnOption.continuous;
Future<void> _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<void>(
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<StackColors>()!.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<StackColors>()!
.textFieldDefaultBG,
shadows: const [],
icon: SvgPicture.asset(
Assets.svg.arrowLeft,
width: 18,
height: 18,
color: Theme.of(context)
.extension<StackColors>()!
.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<StackColors>()!
.radioButtonIconBorder,
),
const SizedBox(
width: 8,
),
RichText(
text: TextSpan(
text: "What is churning?",
style: STextStyles.richLink(context).copyWith(
fontSize: 16,
),
recognizer: TapGestureRecognizer()
..onTap = () {
showDialog<dynamic>(
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<StackColors>()!
.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<ChurnOption>(
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<StackColors>()!
.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<StackColors>()!
.textFieldActiveSearchIconRight,
),
),
dropdownStyleData: DropdownStyleData(
offset: const Offset(0, -10),
elevation: 0,
decoration: BoxDecoration(
color: Theme.of(context)
.extension<StackColors>()!
.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,
),
],
),
),
),
],
),
),
],
),
);
}
}

View file

@ -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<ChurnDialogView> createState() => _ChurnDialogViewState();
}
class _ChurnDialogViewState extends ConsumerState<ChurnDialogView> {
Future<bool> _requestAndProcessCancel() async {
final bool? shouldCancel = await showDialog<bool?>(
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<void>(
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<StackColors>()!
.textSubtitle1,
),
textAlign: TextAlign.center,
),
)
: RoundedContainer(
color: Theme.of(context)
.extension<StackColors>()!
.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<StackColors>()!
.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();
}
}
}
},
),
),
],
),
],
),
),
],
),
),
);
}
}

View file

@ -45,6 +45,7 @@ import '../../../../widgets/desktop/primary_button.dart';
import '../../../../widgets/desktop/secondary_button.dart'; import '../../../../widgets/desktop/secondary_button.dart';
import '../../../../widgets/loading_indicator.dart'; import '../../../../widgets/loading_indicator.dart';
import '../../../cashfusion/desktop_cashfusion_view.dart'; import '../../../cashfusion/desktop_cashfusion_view.dart';
import '../../../churning/desktop_churning_view.dart';
import '../../../coin_control/desktop_coin_control_view.dart'; import '../../../coin_control/desktop_coin_control_view.dart';
import '../../../desktop_menu.dart'; import '../../../desktop_menu.dart';
import '../../../ordinals/desktop_ordinals_view.dart'; import '../../../ordinals/desktop_ordinals_view.dart';
@ -92,6 +93,7 @@ class _DesktopWalletFeaturesState extends ConsumerState<DesktopWalletFeatures> {
onOrdinalsPressed: _onOrdinalsPressed, onOrdinalsPressed: _onOrdinalsPressed,
onMonkeyPressed: _onMonkeyPressed, onMonkeyPressed: _onMonkeyPressed,
onFusionPressed: _onFusionPressed, onFusionPressed: _onFusionPressed,
onChurnPressed: _onChurnPressed,
), ),
); );
} }
@ -348,6 +350,15 @@ class _DesktopWalletFeaturesState extends ConsumerState<DesktopWalletFeatures> {
); );
} }
void _onChurnPressed() {
Navigator.of(context, rootNavigator: true).pop();
Navigator.of(context).pushNamed(
DesktopChurningView.routeName,
arguments: widget.walletId,
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final wallet = ref.watch(pWallets).getWallet(widget.walletId); final wallet = ref.watch(pWallets).getWallet(widget.walletId);

View file

@ -23,6 +23,7 @@ import '../../../../../utilities/text_styles.dart';
import '../../../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../../../wallets/crypto_currency/crypto_currency.dart';
import '../../../../../wallets/isar/models/wallet_info.dart'; import '../../../../../wallets/isar/models/wallet_info.dart';
import '../../../../../wallets/isar/providers/wallet_info_provider.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/cash_fusion_interface.dart';
import '../../../../../wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart'; import '../../../../../wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart';
import '../../../../../wallets/wallet/wallet_mixin_interfaces/lelantus_interface.dart'; import '../../../../../wallets/wallet/wallet_mixin_interfaces/lelantus_interface.dart';
@ -48,6 +49,7 @@ class MoreFeaturesDialog extends ConsumerStatefulWidget {
required this.onOrdinalsPressed, required this.onOrdinalsPressed,
required this.onMonkeyPressed, required this.onMonkeyPressed,
required this.onFusionPressed, required this.onFusionPressed,
required this.onChurnPressed,
}); });
final String walletId; final String walletId;
@ -58,6 +60,7 @@ class MoreFeaturesDialog extends ConsumerStatefulWidget {
final VoidCallback? onOrdinalsPressed; final VoidCallback? onOrdinalsPressed;
final VoidCallback? onMonkeyPressed; final VoidCallback? onMonkeyPressed;
final VoidCallback? onFusionPressed; final VoidCallback? onFusionPressed;
final VoidCallback? onChurnPressed;
@override @override
ConsumerState<MoreFeaturesDialog> createState() => _MoreFeaturesDialogState(); ConsumerState<MoreFeaturesDialog> createState() => _MoreFeaturesDialogState();
@ -304,6 +307,13 @@ class _MoreFeaturesDialogState extends ConsumerState<MoreFeaturesDialog> {
iconAsset: Assets.svg.cashFusion, iconAsset: Assets.svg.cashFusion,
onPressed: () async => widget.onFusionPressed?.call(), 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) if (wallet is SparkInterface)
_MoreFeaturesClearSparkCacheItem( _MoreFeaturesClearSparkCacheItem(
cryptoCurrency: wallet.cryptoCurrency, cryptoCurrency: wallet.cryptoCurrency,

View file

@ -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<ChurningService, String>(
(ref, walletId) {
final wallet = ref.watch(pWallets.select((s) => s.getWallet(walletId)));
return ChurningService(wallet: wallet as LibMoneroWallet);
},
);

View file

@ -52,6 +52,8 @@ import 'pages/buy_view/buy_quote_preview.dart';
import 'pages/buy_view/buy_view.dart'; import 'pages/buy_view/buy_view.dart';
import 'pages/cashfusion/cashfusion_view.dart'; import 'pages/cashfusion/cashfusion_view.dart';
import 'pages/cashfusion/fusion_progress_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/coin_control_view.dart';
import 'pages/coin_control/utxo_details_view.dart'; import 'pages/coin_control/utxo_details_view.dart';
import 'pages/exchange_view/choose_from_stack_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/address_book_view/desktop_address_book.dart';
import 'pages_desktop_specific/addresses/desktop_wallet_addresses_view.dart'; import 'pages_desktop_specific/addresses/desktop_wallet_addresses_view.dart';
import 'pages_desktop_specific/cashfusion/desktop_cashfusion_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 'pages_desktop_specific/coin_control/desktop_coin_control_view.dart';
// import 'package:stackwallet/pages_desktop_specific/desktop_exchange/desktop_all_buys_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'; import 'pages_desktop_specific/desktop_buy/desktop_buy_view.dart';
@ -779,6 +782,34 @@ class RouteGenerator {
} }
return _routeError("${settings.name} invalid args: ${args.toString()}"); 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: case DesktopCashFusionView.routeName:
if (args is String) { if (args is String) {
return getRoute( return getRoute(
@ -793,6 +824,20 @@ class RouteGenerator {
} }
return _routeError("${settings.name} invalid args: ${args.toString()}"); 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: case GlobalSettingsView.routeName:
return getRoute( return getRoute(
shouldUseMaterialRoute: useMaterialPageRoute, shouldUseMaterialRoute: useMaterialPageRoute,

View file

@ -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<void> 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<void>.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<void> _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);
}
}

View file

@ -205,6 +205,7 @@ class _SVG {
String get robotHead => "assets/svg/robot-head.svg"; String get robotHead => "assets/svg/robot-head.svg";
String get whirlPool => "assets/svg/whirlpool.svg"; String get whirlPool => "assets/svg/whirlpool.svg";
String get cashFusion => "assets/svg/cashfusion-icon.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 fingerprint => "assets/svg/fingerprint.svg";
String get faceId => "assets/svg/faceid.svg"; String get faceId => "assets/svg/faceid.svg";
String get tokens => "assets/svg/tokens.svg"; String get tokens => "assets/svg/tokens.svg";

View file

@ -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<StackColors>()!.buttonBackSecondary,
);
case ChurnStatus.running:
return SvgPicture.asset(
Assets.svg.loader,
color: Theme.of(context).extension<StackColors>()!.accentColorGreen,
);
case ChurnStatus.success:
return SvgPicture.asset(
Assets.svg.checkCircle,
color: Theme.of(context).extension<StackColors>()!.accentColorGreen,
);
case ChurnStatus.failed:
return SvgPicture.asset(
Assets.svg.circleAlert,
color: Theme.of(context).extension<StackColors>()!.textError,
);
}
}
@override
Widget build(BuildContext context) {
return ConditionalParent(
condition: Util.isDesktop,
builder: (child) => RoundedContainer(
padding: EdgeInsets.zero,
color: Theme.of(context).extension<StackColors>()!.popupBG,
borderColor: Theme.of(context).extension<StackColors>()!.background,
child: child,
),
child: RestoringItemCard(
left: SizedBox(
width: 32,
height: 32,
child: RoundedContainer(
padding: const EdgeInsets.all(0),
color:
Theme.of(context).extension<StackColors>()!.buttonBackSecondary,
child: Center(
child: SvgPicture.asset(
iconAsset,
width: 18,
height: 18,
color: Theme.of(context).extension<StackColors>()!.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<StackColors>()!.textError,
),
)
: null,
),
);
}
}

View file

@ -1,18 +1,31 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../utilities/text_styles.dart'; import '../../utilities/text_styles.dart';
class CheckboxTextButton extends StatefulWidget { 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 String label;
final void Function(bool)? onChanged; final void Function(bool)? onChanged;
final bool initialValue;
@override @override
State<CheckboxTextButton> createState() => _CheckboxTextButtonState(); State<CheckboxTextButton> createState() => _CheckboxTextButtonState();
} }
class _CheckboxTextButtonState extends State<CheckboxTextButton> { class _CheckboxTextButtonState extends State<CheckboxTextButton> {
bool _value = false; late bool _value;
@override
void initState() {
super.initState();
_value = widget.initialValue;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View file

@ -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<StackColors>()!.bottomNavIconIcon,
);
}
}

View file

@ -6,7 +6,7 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
``#include <cs_monero_flutter_libs_linux/cs_monero_flutter_libs_linux_plugin.h> #include <cs_monero_flutter_libs_linux/cs_monero_flutter_libs_linux_plugin.h>
#include <desktop_drop/desktop_drop_plugin.h> #include <desktop_drop/desktop_drop_plugin.h>
#include <devicelocale/devicelocale_plugin.h> #include <devicelocale/devicelocale_plugin.h>
#include <flutter_libepiccash/flutter_libepiccash_plugin.h> #include <flutter_libepiccash/flutter_libepiccash_plugin.h>