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/add_wallet_views/new_wallet_recovery_phrase_warning_view/new_wallet_recovery_phrase_warning_view.dart b/lib/pages/add_wallet_views/new_wallet_recovery_phrase_warning_view/new_wallet_recovery_phrase_warning_view.dart
index 275f7ff0e..2b9d787e3 100644
--- a/lib/pages/add_wallet_views/new_wallet_recovery_phrase_warning_view/new_wallet_recovery_phrase_warning_view.dart
+++ b/lib/pages/add_wallet_views/new_wallet_recovery_phrase_warning_view/new_wallet_recovery_phrase_warning_view.dart
@@ -12,8 +12,6 @@ import 'dart:async';
import 'dart:convert';
import 'package:bip39/bip39.dart' as bip39;
-import 'package:blockchain_utils/bip/bip/bip39/bip39_mnemonic.dart';
-import 'package:blockchain_utils/bip/bip/bip39/bip39_mnemonic_generator.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart';
diff --git a/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart b/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart
index cd0115128..9d2ac8c1d 100644
--- a/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart
+++ b/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart
@@ -23,6 +23,7 @@ import '../../../../utilities/format.dart';
import '../../../../utilities/text_styles.dart';
import '../../../../utilities/util.dart';
import '../../../../wallets/crypto_currency/crypto_currency.dart';
+import '../../../../wallets/crypto_currency/interfaces/view_only_option_currency_interface.dart';
import '../../../../widgets/conditional_parent.dart';
import '../../../../widgets/custom_buttons/app_bar_icon_button.dart';
import '../../../../widgets/custom_buttons/checkbox_text_button.dart';
@@ -32,7 +33,9 @@ import '../../../../widgets/desktop/desktop_scaffold.dart';
import '../../../../widgets/expandable.dart';
import '../../../../widgets/rounded_white_container.dart';
import '../../../../widgets/stack_text_field.dart';
+import '../../../../widgets/toggle.dart';
import '../../create_or_restore_wallet_view/sub_widgets/coin_image.dart';
+import '../restore_view_only_wallet_view.dart';
import '../restore_wallet_view.dart';
import '../sub_widgets/mnemonic_word_count_select_sheet.dart';
import 'sub_widgets/mobile_mnemonic_length_selector.dart';
@@ -69,7 +72,6 @@ class _RestoreOptionsViewState extends ConsumerState {
final bool _nextEnabled = true;
DateTime? _restoreFromDate;
bool hidePassword = true;
- bool _expandedAdavnced = false;
bool get supportsMnemonicPassphrase => coin.hasMnemonicPassphraseSupport;
@@ -99,27 +101,46 @@ class _RestoreOptionsViewState extends ConsumerState {
super.dispose();
}
+ bool _nextLock = false;
Future nextPressed() async {
- if (!isDesktop) {
- // hide keyboard if has focus
- if (FocusScope.of(context).hasFocus) {
- FocusScope.of(context).unfocus();
- await Future.delayed(const Duration(milliseconds: 75));
+ if (_nextLock) return;
+ _nextLock = true;
+ try {
+ if (!isDesktop) {
+ // hide keyboard if has focus
+ if (FocusScope.of(context).hasFocus) {
+ FocusScope.of(context).unfocus();
+ await Future.delayed(const Duration(milliseconds: 75));
+ }
}
- }
- if (mounted) {
- await Navigator.of(context).pushNamed(
- RestoreWalletView.routeName,
- arguments: Tuple6(
- walletName,
- coin,
- ref.read(mnemonicWordCountStateProvider.state).state,
- _restoreFromDate,
- passwordController.text,
- enableLelantusScanning,
- ),
- );
+ if (mounted) {
+ if (!_showViewOnlyOption) {
+ await Navigator.of(context).pushNamed(
+ RestoreWalletView.routeName,
+ arguments: Tuple6(
+ walletName,
+ coin,
+ ref.read(mnemonicWordCountStateProvider.state).state,
+ _restoreFromDate,
+ passwordController.text,
+ enableLelantusScanning,
+ ),
+ );
+ } else {
+ await Navigator.of(context).pushNamed(
+ RestoreViewOnlyWalletView.routeName,
+ arguments: (
+ walletName: walletName,
+ coin: coin,
+ restoreFromDate: _restoreFromDate,
+ enableLelantusScanning: enableLelantusScanning,
+ ),
+ );
+ }
+ }
+ } finally {
+ _nextLock = false;
}
}
@@ -164,17 +185,12 @@ class _RestoreOptionsViewState extends ConsumerState {
);
}
+ bool _showViewOnlyOption = false;
+
@override
Widget build(BuildContext context) {
debugPrint("BUILD: $runtimeType with ${coin.identifier} $walletName");
- final lengths = coin.possibleMnemonicLengths;
-
- final isMoneroAnd25 = coin is Monero &&
- ref.watch(mnemonicWordCountStateProvider.state).state == 25;
- final isWowneroAnd25 = coin is Wownero &&
- ref.watch(mnemonicWordCountStateProvider.state).state == 25;
-
return MasterScaffold(
isDesktop: isDesktop,
appBar: isDesktop
@@ -227,288 +243,57 @@ class _RestoreOptionsViewState extends ConsumerState {
SizedBox(
height: isDesktop ? 40 : 24,
),
- if (isMoneroAnd25 || coin is Epiccash || isWowneroAnd25)
- Text(
- "Choose start date",
- style: isDesktop
- ? STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(context)
- .extension()!
- .textDark3,
- )
- : STextStyles.smallMed12(context),
- textAlign: TextAlign.left,
- ),
- if (isMoneroAnd25 || coin is Epiccash || isWowneroAnd25)
- SizedBox(
- height: isDesktop ? 16 : 8,
- ),
- if (isMoneroAnd25 || coin is Epiccash || isWowneroAnd25)
- if (!isDesktop)
- RestoreFromDatePicker(
- onTap: chooseDate,
- controller: _dateController,
- ),
- if (isMoneroAnd25 || coin is Epiccash || isWowneroAnd25)
- if (isDesktop)
- // TODO desktop date picker
- RestoreFromDatePicker(
- onTap: chooseDesktopDate,
- controller: _dateController,
- ),
- if (isMoneroAnd25 || coin is Epiccash || isWowneroAnd25)
- const SizedBox(
- height: 8,
- ),
- if (isMoneroAnd25 || coin is Epiccash || isWowneroAnd25)
- RoundedWhiteContainer(
- child: Center(
- child: Text(
- "Choose the date you made the wallet (approximate is fine)",
- style: isDesktop
- ? STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(context)
- .extension()!
- .textSubtitle1,
- )
- : STextStyles.smallMed12(context).copyWith(
- fontSize: 10,
- ),
- ),
- ),
- ),
- if (isMoneroAnd25 || coin is Epiccash || isWowneroAnd25)
+ if (coin is ViewOnlyOptionCurrencyInterface)
SizedBox(
- height: isDesktop ? 24 : 16,
- ),
- Text(
- "Choose recovery phrase length",
- style: isDesktop
- ? STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(context)
- .extension()!
- .textDark3,
- )
- : STextStyles.smallMed12(context),
- textAlign: TextAlign.left,
- ),
- SizedBox(
- height: isDesktop ? 16 : 8,
- ),
- if (isDesktop)
- DropdownButtonHideUnderline(
- child: DropdownButton2(
- value:
- ref.watch(mnemonicWordCountStateProvider.state).state,
- items: [
- ...lengths.map(
- (e) => DropdownMenuItem(
- value: e,
- child: Text(
- "$e words",
- style: STextStyles.desktopTextMedium(context),
- ),
- ),
- ),
- ],
- onChanged: (value) {
- if (value is int) {
- ref.read(mnemonicWordCountStateProvider.state).state =
- value;
- }
+ height: isDesktop ? 56 : 48,
+ width: isDesktop ? 490 : null,
+ child: Toggle(
+ key: UniqueKey(),
+ onText: "Seed",
+ offText: "View Only",
+ onColor:
+ Theme.of(context).extension()!.popupBG,
+ offColor: Theme.of(context)
+ .extension()!
+ .textFieldDefaultBG,
+ isOn: _showViewOnlyOption,
+ onValueChanged: (value) {
+ setState(() {
+ _showViewOnlyOption = 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()!
- .textFieldDefaultBG,
- borderRadius: BorderRadius.circular(
- Constants.size.circularBorderRadius,
- ),
- ),
- ),
- menuItemStyleData: const MenuItemStyleData(
- padding: EdgeInsets.symmetric(
- horizontal: 16,
- vertical: 8,
+ decoration: BoxDecoration(
+ color: Colors.transparent,
+ borderRadius: BorderRadius.circular(
+ Constants.size.circularBorderRadius,
),
),
),
),
- if (!isDesktop)
- MobileMnemonicLengthSelector(
- chooseMnemonicLength: chooseMnemonicLength,
- ),
- if (supportsMnemonicPassphrase)
+ if (coin is ViewOnlyOptionCurrencyInterface)
SizedBox(
- height: isDesktop ? 24 : 16,
+ height: isDesktop ? 40 : 24,
),
- if (supportsMnemonicPassphrase)
- Expandable(
- onExpandChanged: (state) {
- setState(() {
- _expandedAdavnced = state == ExpandableState.expanded;
- });
- },
- header: Container(
- color: Colors.transparent,
- child: Padding(
- padding: const EdgeInsets.only(
- top: 8.0,
- bottom: 8.0,
- right: 10,
- ),
- child: Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(
- "Advanced",
- style: isDesktop
- ? STextStyles.desktopTextExtraExtraSmall(
- context,
- ).copyWith(
- color: Theme.of(context)
- .extension()!
- .textDark3,
- )
- : STextStyles.smallMed12(context),
- textAlign: TextAlign.left,
- ),
- SvgPicture.asset(
- _expandedAdavnced
- ? Assets.svg.chevronUp
- : Assets.svg.chevronDown,
- width: 12,
- height: 6,
- color: Theme.of(context)
- .extension()!
- .textFieldActiveSearchIconRight,
- ),
- ],
- ),
+ _showViewOnlyOption
+ ? ViewOnlyRestoreOption(
+ coin: coin,
+ dateController: _dateController,
+ dateChooserFunction:
+ isDesktop ? chooseDesktopDate : chooseDate,
+ )
+ : SeedRestoreOption(
+ coin: coin,
+ dateController: _dateController,
+ pwController: passwordController,
+ pwFocusNode: passwordFocusNode,
+ supportsMnemonicPassphrase: supportsMnemonicPassphrase,
+ dateChooserFunction:
+ isDesktop ? chooseDesktopDate : chooseDate,
+ chooseMnemonicLength: chooseMnemonicLength,
+ lelScanChanged: (value) {
+ enableLelantusScanning = value;
+ },
),
- ),
- body: Container(
- color: Colors.transparent,
- child: Column(
- children: [
- if (coin is Firo)
- CheckboxTextButton(
- label: "Scan for Lelantus transactions",
- onChanged: (newValue) {
- setState(() {
- enableLelantusScanning = newValue ?? true;
- });
- },
- ),
- if (coin is Firo)
- const SizedBox(
- height: 8,
- ),
- ClipRRect(
- borderRadius: BorderRadius.circular(
- Constants.size.circularBorderRadius,
- ),
- child: TextField(
- key: const Key("mnemonicPassphraseFieldKey1"),
- focusNode: passwordFocusNode,
- controller: passwordController,
- style: isDesktop
- ? STextStyles.desktopTextMedium(context)
- .copyWith(
- height: 2,
- )
- : STextStyles.field(context),
- obscureText: hidePassword,
- enableSuggestions: false,
- autocorrect: false,
- decoration: standardInputDecoration(
- "BIP39 passphrase",
- passwordFocusNode,
- context,
- ).copyWith(
- suffixIcon: UnconstrainedBox(
- child: ConditionalParent(
- condition: isDesktop,
- builder: (child) => SizedBox(
- height: 70,
- child: child,
- ),
- child: Row(
- children: [
- SizedBox(
- width: isDesktop ? 24 : 16,
- ),
- GestureDetector(
- key: const Key(
- "mnemonicPassphraseFieldShowPasswordButtonKey",
- ),
- onTap: () async {
- setState(() {
- hidePassword = !hidePassword;
- });
- },
- child: SvgPicture.asset(
- hidePassword
- ? Assets.svg.eye
- : Assets.svg.eyeSlash,
- color: Theme.of(context)
- .extension()!
- .textDark3,
- width: isDesktop ? 24 : 16,
- height: isDesktop ? 24 : 16,
- ),
- ),
- const SizedBox(
- width: 12,
- ),
- ],
- ),
- ),
- ),
- ),
- ),
- ),
- const SizedBox(
- height: 8,
- ),
- RoundedWhiteContainer(
- child: Center(
- child: Text(
- "If the recovery phrase you are about to restore "
- "was created with an optional BIP39 passphrase "
- "you can enter it here.",
- style: isDesktop
- ? STextStyles.desktopTextExtraSmall(context)
- .copyWith(
- color: Theme.of(context)
- .extension()!
- .textSubtitle1,
- )
- : STextStyles.itemSubtitle(context),
- ),
- ),
- ),
- const SizedBox(
- height: 16,
- ),
- ],
- ),
- ),
- ),
if (!isDesktop)
const Spacer(
flex: 3,
@@ -532,3 +317,394 @@ class _RestoreOptionsViewState extends ConsumerState {
);
}
}
+
+class SeedRestoreOption extends ConsumerStatefulWidget {
+ const SeedRestoreOption({
+ super.key,
+ required this.coin,
+ required this.dateController,
+ required this.pwController,
+ required this.pwFocusNode,
+ required this.supportsMnemonicPassphrase,
+ required this.dateChooserFunction,
+ required this.chooseMnemonicLength,
+ required this.lelScanChanged,
+ });
+
+ final CryptoCurrency coin;
+ final TextEditingController dateController;
+ final TextEditingController pwController;
+ final FocusNode pwFocusNode;
+ final bool supportsMnemonicPassphrase;
+
+ final Future Function() dateChooserFunction;
+ final Future Function() chooseMnemonicLength;
+ final void Function(bool) lelScanChanged;
+
+ @override
+ ConsumerState createState() => _SeedRestoreOptionState();
+}
+
+class _SeedRestoreOptionState extends ConsumerState {
+ bool _hidePassword = true;
+ bool _expandedAdvanced = false;
+ bool _enableLelantusScanning = false;
+
+ @override
+ Widget build(BuildContext context) {
+ final lengths = widget.coin.possibleMnemonicLengths;
+
+ final isMoneroAnd25 = widget.coin is Monero &&
+ ref.watch(mnemonicWordCountStateProvider.state).state == 25;
+ final isWowneroAnd25 = widget.coin is Wownero &&
+ ref.watch(mnemonicWordCountStateProvider.state).state == 25;
+
+ return Column(
+ children: [
+ if (isMoneroAnd25 || widget.coin is Epiccash || isWowneroAnd25)
+ Text(
+ "Choose start date",
+ style: Util.isDesktop
+ ? STextStyles.desktopTextExtraSmall(context).copyWith(
+ color:
+ Theme.of(context).extension()!.textDark3,
+ )
+ : STextStyles.smallMed12(context),
+ textAlign: TextAlign.left,
+ ),
+ if (isMoneroAnd25 || widget.coin is Epiccash || isWowneroAnd25)
+ SizedBox(
+ height: Util.isDesktop ? 16 : 8,
+ ),
+ if (isMoneroAnd25 || widget.coin is Epiccash || isWowneroAnd25)
+ RestoreFromDatePicker(
+ onTap: widget.dateChooserFunction,
+ controller: widget.dateController,
+ ),
+ if (isMoneroAnd25 || widget.coin is Epiccash || isWowneroAnd25)
+ const SizedBox(
+ height: 8,
+ ),
+ if (isMoneroAnd25 || widget.coin is Epiccash || isWowneroAnd25)
+ RoundedWhiteContainer(
+ child: Center(
+ child: Text(
+ "Choose the date you made the wallet (approximate is fine)",
+ style: Util.isDesktop
+ ? STextStyles.desktopTextExtraSmall(context).copyWith(
+ color: Theme.of(context)
+ .extension()!
+ .textSubtitle1,
+ )
+ : STextStyles.smallMed12(context).copyWith(
+ fontSize: 10,
+ ),
+ ),
+ ),
+ ),
+ if (isMoneroAnd25 || widget.coin is Epiccash || isWowneroAnd25)
+ SizedBox(
+ height: Util.isDesktop ? 24 : 16,
+ ),
+ Text(
+ "Choose recovery phrase length",
+ style: Util.isDesktop
+ ? STextStyles.desktopTextExtraSmall(context).copyWith(
+ color: Theme.of(context).extension()!.textDark3,
+ )
+ : STextStyles.smallMed12(context),
+ textAlign: TextAlign.left,
+ ),
+ SizedBox(
+ height: Util.isDesktop ? 16 : 8,
+ ),
+ if (Util.isDesktop)
+ DropdownButtonHideUnderline(
+ child: DropdownButton2(
+ value: ref.watch(mnemonicWordCountStateProvider.state).state,
+ items: [
+ ...lengths.map(
+ (e) => DropdownMenuItem(
+ value: e,
+ child: Text(
+ "$e words",
+ style: STextStyles.desktopTextMedium(context),
+ ),
+ ),
+ ),
+ ],
+ onChanged: (value) {
+ if (value is int) {
+ ref.read(mnemonicWordCountStateProvider.state).state = 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()!
+ .textFieldDefaultBG,
+ borderRadius: BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ ),
+ ),
+ ),
+ menuItemStyleData: const MenuItemStyleData(
+ padding: EdgeInsets.symmetric(
+ horizontal: 16,
+ vertical: 8,
+ ),
+ ),
+ ),
+ ),
+ if (!Util.isDesktop)
+ MobileMnemonicLengthSelector(
+ chooseMnemonicLength: widget.chooseMnemonicLength,
+ ),
+ if (widget.supportsMnemonicPassphrase)
+ SizedBox(
+ height: Util.isDesktop ? 24 : 16,
+ ),
+ if (widget.supportsMnemonicPassphrase)
+ Expandable(
+ onExpandChanged: (state) {
+ setState(() {
+ _expandedAdvanced = state == ExpandableState.expanded;
+ });
+ },
+ header: Container(
+ color: Colors.transparent,
+ child: Padding(
+ padding: const EdgeInsets.only(
+ top: 8.0,
+ bottom: 8.0,
+ right: 10,
+ ),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ "Advanced",
+ style: Util.isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(
+ context,
+ ).copyWith(
+ color: Theme.of(context)
+ .extension()!
+ .textDark3,
+ )
+ : STextStyles.smallMed12(context),
+ textAlign: TextAlign.left,
+ ),
+ SvgPicture.asset(
+ _expandedAdvanced
+ ? Assets.svg.chevronUp
+ : Assets.svg.chevronDown,
+ width: 12,
+ height: 6,
+ color: Theme.of(context)
+ .extension()!
+ .textFieldActiveSearchIconRight,
+ ),
+ ],
+ ),
+ ),
+ ),
+ body: Container(
+ color: Colors.transparent,
+ child: Column(
+ children: [
+ if (widget.coin is Firo)
+ CheckboxTextButton(
+ label: "Scan for Lelantus transactions",
+ onChanged: (newValue) {
+ setState(() {
+ _enableLelantusScanning = newValue ?? true;
+ });
+
+ widget.lelScanChanged(_enableLelantusScanning);
+ },
+ ),
+ if (widget.coin is Firo)
+ const SizedBox(
+ height: 8,
+ ),
+ ClipRRect(
+ borderRadius: BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ ),
+ child: TextField(
+ key: const Key("mnemonicPassphraseFieldKey1"),
+ focusNode: widget.pwFocusNode,
+ controller: widget.pwController,
+ style: Util.isDesktop
+ ? STextStyles.desktopTextMedium(context).copyWith(
+ height: 2,
+ )
+ : STextStyles.field(context),
+ obscureText: _hidePassword,
+ enableSuggestions: false,
+ autocorrect: false,
+ decoration: standardInputDecoration(
+ "BIP39 passphrase",
+ widget.pwFocusNode,
+ context,
+ ).copyWith(
+ suffixIcon: UnconstrainedBox(
+ child: ConditionalParent(
+ condition: Util.isDesktop,
+ builder: (child) => SizedBox(
+ height: 70,
+ child: child,
+ ),
+ child: Row(
+ children: [
+ SizedBox(
+ width: Util.isDesktop ? 24 : 16,
+ ),
+ GestureDetector(
+ key: const Key(
+ "mnemonicPassphraseFieldShowPasswordButtonKey",
+ ),
+ onTap: () async {
+ setState(() {
+ _hidePassword = !_hidePassword;
+ });
+ },
+ child: SvgPicture.asset(
+ _hidePassword
+ ? Assets.svg.eye
+ : Assets.svg.eyeSlash,
+ color: Theme.of(context)
+ .extension()!
+ .textDark3,
+ width: Util.isDesktop ? 24 : 16,
+ height: Util.isDesktop ? 24 : 16,
+ ),
+ ),
+ const SizedBox(
+ width: 12,
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(
+ height: 8,
+ ),
+ RoundedWhiteContainer(
+ child: Center(
+ child: Text(
+ "If the recovery phrase you are about to restore "
+ "was created with an optional BIP39 passphrase "
+ "you can enter it here.",
+ style: Util.isDesktop
+ ? STextStyles.desktopTextExtraSmall(context)
+ .copyWith(
+ color: Theme.of(context)
+ .extension()!
+ .textSubtitle1,
+ )
+ : STextStyles.itemSubtitle(context),
+ ),
+ ),
+ ),
+ const SizedBox(
+ height: 16,
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+class ViewOnlyRestoreOption extends StatefulWidget {
+ const ViewOnlyRestoreOption({
+ super.key,
+ required this.coin,
+ required this.dateController,
+ required this.dateChooserFunction,
+ });
+
+ final CryptoCurrency coin;
+ final TextEditingController dateController;
+
+ final Future Function() dateChooserFunction;
+
+ @override
+ State createState() => _ViewOnlyRestoreOptionState();
+}
+
+class _ViewOnlyRestoreOptionState extends State {
+ @override
+ Widget build(BuildContext context) {
+ final showDateOption = widget.coin is ViewOnlyOptionCurrencyInterface;
+ return Column(
+ children: [
+ if (showDateOption)
+ Text(
+ "Choose start date",
+ style: Util.isDesktop
+ ? STextStyles.desktopTextExtraSmall(context).copyWith(
+ color:
+ Theme.of(context).extension()!.textDark3,
+ )
+ : STextStyles.smallMed12(context),
+ textAlign: TextAlign.left,
+ ),
+ if (showDateOption)
+ SizedBox(
+ height: Util.isDesktop ? 16 : 8,
+ ),
+ if (showDateOption)
+ RestoreFromDatePicker(
+ onTap: widget.dateChooserFunction,
+ controller: widget.dateController,
+ ),
+ if (showDateOption)
+ const SizedBox(
+ height: 8,
+ ),
+ if (showDateOption)
+ RoundedWhiteContainer(
+ child: Center(
+ child: Text(
+ "Choose the date you made the wallet (approximate is fine)",
+ style: Util.isDesktop
+ ? STextStyles.desktopTextExtraSmall(context).copyWith(
+ color: Theme.of(context)
+ .extension()!
+ .textSubtitle1,
+ )
+ : STextStyles.smallMed12(context).copyWith(
+ fontSize: 10,
+ ),
+ ),
+ ),
+ ),
+ if (showDateOption)
+ SizedBox(
+ height: Util.isDesktop ? 24 : 16,
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/pages/add_wallet_views/restore_wallet_view/restore_view_only_wallet_view.dart b/lib/pages/add_wallet_views/restore_wallet_view/restore_view_only_wallet_view.dart
new file mode 100644
index 000000000..5f2bd08b0
--- /dev/null
+++ b/lib/pages/add_wallet_views/restore_wallet_view/restore_view_only_wallet_view.dart
@@ -0,0 +1,393 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:cs_monero/src/deprecated/get_height_by_date.dart'
+ as cs_monero_deprecated;
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:wakelock_plus/wakelock_plus.dart';
+
+import '../../../pages_desktop_specific/desktop_home_view.dart';
+import '../../../pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart';
+import '../../../providers/db/main_db_provider.dart';
+import '../../../providers/global/secure_store_provider.dart';
+import '../../../providers/providers.dart';
+import '../../../themes/stack_colors.dart';
+import '../../../utilities/barcode_scanner_interface.dart';
+import '../../../utilities/clipboard_interface.dart';
+import '../../../utilities/text_styles.dart';
+import '../../../utilities/util.dart';
+import '../../../wallets/crypto_currency/crypto_currency.dart';
+import '../../../wallets/isar/models/wallet_info.dart';
+import '../../../wallets/wallet/impl/epiccash_wallet.dart';
+import '../../../wallets/wallet/impl/monero_wallet.dart';
+import '../../../wallets/wallet/impl/wownero_wallet.dart';
+import '../../../wallets/wallet/wallet.dart';
+import '../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart';
+import '../../../widgets/custom_buttons/app_bar_icon_button.dart';
+import '../../../widgets/desktop/desktop_app_bar.dart';
+import '../../../widgets/desktop/desktop_scaffold.dart';
+import '../../../widgets/desktop/primary_button.dart';
+import '../../../widgets/stack_text_field.dart';
+import '../../home_view/home_view.dart';
+import 'confirm_recovery_dialog.dart';
+import 'sub_widgets/restore_failed_dialog.dart';
+import 'sub_widgets/restore_succeeded_dialog.dart';
+import 'sub_widgets/restoring_dialog.dart';
+
+class RestoreViewOnlyWalletView extends ConsumerStatefulWidget {
+ const RestoreViewOnlyWalletView({
+ super.key,
+ required this.walletName,
+ required this.coin,
+ required this.restoreFromDate,
+ this.enableLelantusScanning = false,
+ this.barcodeScanner = const BarcodeScannerWrapper(),
+ this.clipboard = const ClipboardWrapper(),
+ });
+
+ static const routeName = "/restoreViewOnlyWallet";
+
+ final String walletName;
+ final CryptoCurrency coin;
+ final DateTime? restoreFromDate;
+ final bool enableLelantusScanning;
+ final BarcodeScannerInterface barcodeScanner;
+ final ClipboardInterface clipboard;
+
+ @override
+ ConsumerState createState() =>
+ _RestoreViewOnlyWalletViewState();
+}
+
+class _RestoreViewOnlyWalletViewState
+ extends ConsumerState {
+ late final TextEditingController addressController;
+ late final TextEditingController viewKeyController;
+
+ bool _enableRestoreButton = false;
+
+ bool _buttonLock = false;
+
+ Future _requestRestore() async {
+ if (_buttonLock) return;
+ _buttonLock = true;
+
+ try {
+ if (!Util.isDesktop) {
+ // wait for keyboard to disappear
+ FocusScope.of(context).unfocus();
+ await Future.delayed(
+ const Duration(milliseconds: 100),
+ );
+ }
+
+ if (mounted) {
+ await showDialog(
+ context: context,
+ useSafeArea: false,
+ barrierDismissible: true,
+ builder: (context) {
+ return ConfirmRecoveryDialog(
+ onConfirm: _attemptRestore,
+ );
+ },
+ );
+ }
+ } finally {
+ _buttonLock = false;
+ }
+ }
+
+ Future _attemptRestore() async {
+ int height = 0;
+ final Map otherDataJson = {
+ WalletInfoKeys.isViewOnlyKey: true,
+ };
+
+ if (widget.restoreFromDate != null) {
+ if (widget.coin is Monero) {
+ height = cs_monero_deprecated.getMoneroHeightByDate(
+ date: widget.restoreFromDate!,
+ );
+ }
+ if (widget.coin is Wownero) {
+ height = cs_monero_deprecated.getWowneroHeightByDate(
+ date: widget.restoreFromDate!,
+ );
+ }
+ if (height < 0) {
+ height = 0;
+ }
+ }
+
+ if (widget.coin is Firo) {
+ otherDataJson.addAll(
+ {
+ WalletInfoKeys.lelantusCoinIsarRescanRequired: false,
+ WalletInfoKeys.enableLelantusScanning: widget.enableLelantusScanning,
+ },
+ );
+ }
+
+ if (!Platform.isLinux && !Util.isDesktop) await WakelockPlus.enable();
+
+ try {
+ final info = WalletInfo.createNew(
+ coin: widget.coin,
+ name: widget.walletName,
+ restoreHeight: height,
+ otherDataJsonString: jsonEncode(otherDataJson),
+ );
+
+ bool isRestoring = true;
+ // show restoring in progress
+
+ if (mounted) {
+ unawaited(
+ showDialog(
+ context: context,
+ useSafeArea: false,
+ barrierDismissible: false,
+ builder: (context) {
+ return RestoringDialog(
+ onCancel: () async {
+ isRestoring = false;
+
+ await ref.read(pWallets).deleteWallet(
+ info,
+ ref.read(secureStoreProvider),
+ );
+ },
+ );
+ },
+ ),
+ );
+ }
+
+ var node = ref
+ .read(nodeServiceChangeNotifierProvider)
+ .getPrimaryNodeFor(currency: widget.coin);
+
+ if (node == null) {
+ node = widget.coin.defaultNode;
+ await ref.read(nodeServiceChangeNotifierProvider).setPrimaryNodeFor(
+ coin: widget.coin,
+ node: node,
+ );
+ }
+
+ try {
+ final wallet = await Wallet.create(
+ walletInfo: info,
+ mainDB: ref.read(mainDBProvider),
+ secureStorageInterface: ref.read(secureStoreProvider),
+ nodeService: ref.read(nodeServiceChangeNotifierProvider),
+ prefs: ref.read(prefsChangeNotifierProvider),
+ viewOnlyData: ViewOnlyWalletData(
+ address: addressController.text,
+ privateViewKey: viewKeyController.text,
+ ),
+ );
+
+ // TODO: extract interface with isRestore param
+ switch (wallet.runtimeType) {
+ case const (EpiccashWallet):
+ await (wallet as EpiccashWallet).init(isRestore: true);
+ break;
+
+ case const (MoneroWallet):
+ await (wallet as MoneroWallet).init(isRestore: true);
+ break;
+
+ case const (WowneroWallet):
+ await (wallet as WowneroWallet).init(isRestore: true);
+ break;
+
+ default:
+ await wallet.init();
+ }
+
+ await wallet.recover(isRescan: false);
+
+ // check if state is still active before continuing
+ if (mounted) {
+ // don't remove this setMnemonicVerified thing
+ await wallet.info.setMnemonicVerified(
+ isar: ref.read(mainDBProvider).isar,
+ );
+
+ ref.read(pWallets).addWallet(wallet);
+
+ if (mounted) {
+ if (Util.isDesktop) {
+ Navigator.of(context).popUntil(
+ ModalRoute.withName(
+ DesktopHomeView.routeName,
+ ),
+ );
+ } else {
+ unawaited(
+ Navigator.of(context).pushNamedAndRemoveUntil(
+ HomeView.routeName,
+ (route) => false,
+ ),
+ );
+ }
+
+ await showDialog(
+ context: context,
+ useSafeArea: false,
+ barrierDismissible: true,
+ builder: (context) {
+ return const RestoreSucceededDialog();
+ },
+ );
+ }
+ }
+ } catch (e) {
+ // check if state is still active and restore wasn't cancelled
+ // before continuing
+ if (mounted && isRestoring) {
+ // pop waiting dialog
+ Navigator.pop(context);
+
+ // show restoring wallet failed dialog
+ await showDialog(
+ context: context,
+ useSafeArea: false,
+ barrierDismissible: true,
+ builder: (context) {
+ return RestoreFailedDialog(
+ errorMessage: e.toString(),
+ walletId: info.walletId,
+ walletName: info.name,
+ );
+ },
+ );
+ }
+ }
+ } finally {
+ if (!Platform.isLinux && !Util.isDesktop) await WakelockPlus.disable();
+ }
+ }
+
+ @override
+ void initState() {
+ super.initState();
+ addressController = TextEditingController();
+ viewKeyController = TextEditingController();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final isDesktop = Util.isDesktop;
+ return MasterScaffold(
+ isDesktop: isDesktop,
+ appBar: isDesktop
+ ? const DesktopAppBar(
+ isCompactHeight: false,
+ leading: AppBarBackButton(),
+ trailing: ExitToMyStackButton(),
+ )
+ : AppBar(
+ leading: AppBarBackButton(
+ onPressed: () async {
+ if (FocusScope.of(context).hasFocus) {
+ FocusScope.of(context).unfocus();
+ await Future.delayed(
+ const Duration(milliseconds: 50),
+ );
+ }
+ if (context.mounted) {
+ Navigator.of(context).pop();
+ }
+ },
+ ),
+ ),
+ body: Container(
+ color: Theme.of(context).extension()!.background,
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ return SingleChildScrollView(
+ child: ConstrainedBox(
+ constraints: BoxConstraints(
+ minHeight: constraints.maxHeight,
+ maxWidth: isDesktop ? 480 : double.infinity,
+ ),
+ child: IntrinsicHeight(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ children: [
+ if (isDesktop)
+ const Spacer(
+ flex: 10,
+ ),
+ if (!isDesktop)
+ Text(
+ widget.walletName,
+ style: STextStyles.itemSubtitle(context),
+ ),
+ SizedBox(
+ height: isDesktop ? 0 : 4,
+ ),
+ Text(
+ "Enter view only details",
+ style: isDesktop
+ ? STextStyles.desktopH2(context)
+ : STextStyles.pageTitleH1(context),
+ ),
+ SizedBox(
+ height: isDesktop ? 24 : 16,
+ ),
+ FullTextField(
+ label: "Address",
+ controller: addressController,
+ onChanged: (newValue) {
+ setState(() {
+ _enableRestoreButton = newValue.isNotEmpty &&
+ viewKeyController.text.isNotEmpty;
+ });
+ },
+ ),
+ SizedBox(
+ height: isDesktop ? 16 : 12,
+ ),
+ FullTextField(
+ label: "View Key",
+ controller: viewKeyController,
+ onChanged: (value) {
+ setState(() {
+ _enableRestoreButton = value.isNotEmpty &&
+ addressController.text.isNotEmpty;
+ });
+ },
+ ),
+ if (!isDesktop) const Spacer(),
+ SizedBox(
+ height: isDesktop ? 24 : 16,
+ ),
+ PrimaryButton(
+ enabled: _enableRestoreButton,
+ onPressed: _requestRestore,
+ width: isDesktop ? 480 : null,
+ label: "Restore",
+ ),
+ if (isDesktop)
+ const Spacer(
+ flex: 15,
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart b/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart
index ce2d20832..2fae2352c 100644
--- a/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart
+++ b/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart
@@ -655,16 +655,18 @@ class _RestoreWalletViewState extends ConsumerState {
const Duration(milliseconds: 100),
);
- await showDialog(
- context: context,
- useSafeArea: false,
- barrierDismissible: true,
- builder: (context) {
- return ConfirmRecoveryDialog(
- onConfirm: attemptRestore,
- );
- },
- );
+ if (mounted) {
+ await showDialog(
+ context: context,
+ useSafeArea: false,
+ barrierDismissible: true,
+ builder: (context) {
+ return ConfirmRecoveryDialog(
+ onConfirm: attemptRestore,
+ );
+ },
+ );
+ }
}
@override
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..8e732e643
--- /dev/null
+++ b/lib/pages/churning/churning_progress_view.dart
@@ -0,0 +1,259 @@
+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.alertCircle,
+ label: "Waiting for balance to unlock ${ref.watch(
+ pChurningService(widget.walletId)
+ .select((s) => s.confirmsInfo),
+ ) ?? ""}",
+ status: ref.watch(
+ pChurningService(widget.walletId)
+ .select((s) => s.waitingForUnlockedBalance),
+ ),
+ ),
+ const SizedBox(
+ height: 12,
+ ),
+ ProgressItem(
+ iconAsset: Assets.svg.churn,
+ 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..d59042e5e
--- /dev/null
+++ b/lib/pages/churning/churning_view.dart
@@ -0,0 +1,292 @@
+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_dialog.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 {
+ await showDialog(
+ context: context,
+ builder: (context) => const StackOkDialog(
+ title: "What is churning?",
+ message: "Churning in a Monero wallet involves"
+ " sending Monero to oneself in multiple"
+ " transactions, which can enhance privacy"
+ " by making it harder for observers to "
+ "link your transactions. This process"
+ " re-mixes the funds within the network,"
+ " helping obscure transaction history. "
+ "Churning is optional and mainly beneficial"
+ " in scenarios where maximum privacy is"
+ " desired or if you received the Monero from"
+ " a source from which you'd like to disassociate.",
+ ),
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ 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/settings_views/wallet_settings_view/wallet_settings_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart
index 22d3a4073..494834b02 100644
--- a/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart
+++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart
@@ -39,6 +39,7 @@ import '../../../wallets/wallet/impl/epiccash_wallet.dart';
import '../../../wallets/wallet/intermediate/lib_monero_wallet.dart';
import '../../../wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart';
import '../../../wallets/wallet/wallet_mixin_interfaces/mnemonic_interface.dart';
+import '../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart';
import '../../../widgets/background.dart';
import '../../../widgets/custom_buttons/app_bar_icon_button.dart';
import '../../../widgets/desktop/secondary_button.dart';
@@ -273,6 +274,7 @@ class _WalletSettingsViewState extends ConsumerState {
String keys
})? prevGen,
})? frostWalletData;
+ ViewOnlyWalletData? voData;
if (wallet is BitcoinFrostWallet) {
final futures = [
wallet.getSerializedKeys(),
@@ -298,10 +300,17 @@ class _WalletSettingsViewState extends ConsumerState {
),
);
}
- } else if (wallet
- is MnemonicInterface) {
- mnemonic =
- await wallet.getMnemonicAsWords();
+ } else {
+ if (wallet
+ is ViewOnlyOptionInterface &&
+ wallet.isViewOnly) {
+ voData = await wallet
+ .getViewOnlyWalletData();
+ } else if (wallet
+ is MnemonicInterface) {
+ mnemonic = await wallet
+ .getMnemonicAsWords();
+ }
}
KeyDataInterface? keyData;
@@ -312,36 +321,68 @@ class _WalletSettingsViewState extends ConsumerState {
}
if (context.mounted) {
- await Navigator.push(
- context,
- RouteGenerator.getRoute(
- shouldUseMaterialRoute:
- RouteGenerator
- .useMaterialPageRoute,
- builder: (_) => LockscreenView(
- routeOnSuccessArguments: (
- walletId: walletId,
- mnemonic: mnemonic ?? [],
- frostWalletData:
- frostWalletData,
- keyData: keyData,
+ if (voData != null) {
+ await Navigator.push(
+ context,
+ RouteGenerator.getRoute(
+ shouldUseMaterialRoute:
+ RouteGenerator
+ .useMaterialPageRoute,
+ builder: (_) => LockscreenView(
+ routeOnSuccessArguments: (
+ walletId: walletId,
+ keyData: keyData,
+ ),
+ showBackButton: true,
+ routeOnSuccess:
+ MobileKeyDataView
+ .routeName,
+ biometricsCancelButtonString:
+ "CANCEL",
+ biometricsLocalizedReason:
+ "Authenticate to view recovery data",
+ biometricsAuthenticationTitle:
+ "View recovery data",
+ ),
+ settings: const RouteSettings(
+ name:
+ "/viewRecoveryDataLockscreen",
),
- showBackButton: true,
- routeOnSuccess:
- WalletBackupView.routeName,
- biometricsCancelButtonString:
- "CANCEL",
- biometricsLocalizedReason:
- "Authenticate to view recovery phrase",
- biometricsAuthenticationTitle:
- "View recovery phrase",
),
- settings: const RouteSettings(
- name:
- "/viewRecoverPhraseLockscreen",
+ );
+ } else {
+ await Navigator.push(
+ context,
+ RouteGenerator.getRoute(
+ shouldUseMaterialRoute:
+ RouteGenerator
+ .useMaterialPageRoute,
+ builder: (_) => LockscreenView(
+ routeOnSuccessArguments: (
+ walletId: walletId,
+ mnemonic: mnemonic ?? [],
+ frostWalletData:
+ frostWalletData,
+ keyData: keyData,
+ ),
+ showBackButton: true,
+ routeOnSuccess:
+ WalletBackupView
+ .routeName,
+ biometricsCancelButtonString:
+ "CANCEL",
+ biometricsLocalizedReason:
+ "Authenticate to view recovery phrase",
+ biometricsAuthenticationTitle:
+ "View recovery phrase",
+ ),
+ settings: const RouteSettings(
+ name:
+ "/viewRecoverPhraseLockscreen",
+ ),
),
- ),
- );
+ );
+ }
}
},
);
diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_view_only_wallet_keys_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_view_only_wallet_keys_view.dart
new file mode 100644
index 000000000..cfa60e76c
--- /dev/null
+++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_view_only_wallet_keys_view.dart
@@ -0,0 +1,204 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../../../app_config.dart';
+import '../../../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/delete_wallet_keys_popup.dart';
+import '../../../../providers/global/secure_store_provider.dart';
+import '../../../../providers/global/wallets_provider.dart';
+import '../../../../route_generator.dart';
+import '../../../../themes/stack_colors.dart';
+import '../../../../utilities/text_styles.dart';
+import '../../../../utilities/util.dart';
+import '../../../../wallets/isar/providers/wallet_info_provider.dart';
+import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart';
+import '../../../../widgets/conditional_parent.dart';
+import '../../../../widgets/custom_buttons/app_bar_icon_button.dart';
+import '../../../../widgets/custom_buttons/simple_copy_button.dart';
+import '../../../../widgets/desktop/primary_button.dart';
+import '../../../../widgets/detail_item.dart';
+import '../../../../widgets/rounded_white_container.dart';
+import '../../../../widgets/stack_dialog.dart';
+import '../../../home_view/home_view.dart';
+import '../../../wallet_view/transaction_views/tx_v2/transaction_v2_details_view.dart';
+
+class DeleteViewOnlyWalletKeysView extends ConsumerStatefulWidget {
+ const DeleteViewOnlyWalletKeysView({
+ super.key,
+ required this.walletId,
+ required this.data,
+ });
+
+ static const routeName = "/deleteWalletViewOnlyData";
+
+ final String walletId;
+ final ViewOnlyWalletData data;
+
+ @override
+ ConsumerState createState() =>
+ _DeleteViewOnlyWalletKeysViewState();
+}
+
+class _DeleteViewOnlyWalletKeysViewState
+ extends ConsumerState {
+ bool _lock = false;
+ void _continuePressed() async {
+ if (_lock) {
+ return;
+ }
+ _lock = true;
+ try {
+ if (Util.isDesktop) {
+ await Navigator.of(context).push(
+ RouteGenerator.getRoute(
+ builder: (context) {
+ return ConfirmDelete(
+ walletId: widget.walletId,
+ );
+ },
+ settings: const RouteSettings(
+ name: "/desktopConfirmDelete",
+ ),
+ ),
+ );
+ } else {
+ await showDialog(
+ barrierDismissible: true,
+ context: context,
+ builder: (_) => StackDialog(
+ title: "Thanks! Your wallet will be deleted.",
+ leftButton: TextButton(
+ style: Theme.of(context)
+ .extension()!
+ .getSecondaryEnabledButtonStyle(context),
+ onPressed: () {
+ Navigator.pop(context);
+ },
+ child: Text(
+ "Cancel",
+ style: STextStyles.button(context).copyWith(
+ color: Theme.of(context)
+ .extension()!
+ .accentColorDark,
+ ),
+ ),
+ ),
+ rightButton: TextButton(
+ style: Theme.of(context)
+ .extension()!
+ .getPrimaryEnabledButtonStyle(context),
+ onPressed: () async {
+ await ref.read(pWallets).deleteWallet(
+ ref.read(pWalletInfo(widget.walletId)),
+ ref.read(secureStoreProvider),
+ );
+
+ if (mounted) {
+ Navigator.of(context).popUntil(
+ ModalRoute.withName(HomeView.routeName),
+ );
+ }
+ },
+ child: Text(
+ "Ok",
+ style: STextStyles.button(context),
+ ),
+ ),
+ ),
+ );
+ }
+ } finally {
+ _lock = false;
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return ConditionalParent(
+ condition: !Util.isDesktop,
+ builder: (child) => Scaffold(
+ backgroundColor: Theme.of(context).extension()!.background,
+ appBar: AppBar(
+ leading: AppBarBackButton(
+ onPressed: () {
+ Navigator.of(context).pop();
+ },
+ ),
+ ),
+ body: SafeArea(
+ child: LayoutBuilder(
+ builder: (context, cons) {
+ return SingleChildScrollView(
+ child: ConstrainedBox(
+ constraints: BoxConstraints(minHeight: cons.maxHeight),
+ child: IntrinsicHeight(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: child,
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ RoundedWhiteContainer(
+ child: Text(
+ "Please write down your backup data. Keep it safe and "
+ "never share it with anyone. "
+ "Your backup data is the only way you can access your "
+ "wallet if you forget your PIN, lose your phone, etc."
+ "\n\n"
+ "${AppConfig.appName} does not keep nor is able to restore "
+ "your backup data. "
+ "Only you have access to your wallet.",
+ style: STextStyles.label(context),
+ ),
+ ),
+ const SizedBox(
+ height: 24,
+ ),
+ if (widget.data.address != null)
+ DetailItem(
+ title: "Address",
+ detail: widget.data.address!,
+ button: Util.isDesktop
+ ? IconCopyButton(
+ data: widget.data.address!,
+ )
+ : SimpleCopyButton(
+ data: widget.data.address!,
+ ),
+ ),
+ if (widget.data.address != null)
+ const SizedBox(
+ height: 16,
+ ),
+ if (widget.data.privateViewKey != null)
+ DetailItem(
+ title: "Private view key",
+ detail: widget.data.privateViewKey!,
+ button: Util.isDesktop
+ ? IconCopyButton(
+ data: widget.data.privateViewKey!,
+ )
+ : SimpleCopyButton(
+ data: widget.data.privateViewKey!,
+ ),
+ ),
+ if (!Util.isDesktop) const Spacer(),
+ const SizedBox(
+ height: 16,
+ ),
+ PrimaryButton(
+ label: "Continue",
+ onPressed: _continuePressed,
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_recovery_phrase_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_recovery_phrase_view.dart
index f418ed230..16c48cbb6 100644
--- a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_recovery_phrase_view.dart
+++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_recovery_phrase_view.dart
@@ -14,11 +14,9 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/svg.dart';
+
import '../../../../app_config.dart';
import '../../../../notifications/show_flush_bar.dart';
-import '../../../add_wallet_views/new_wallet_recovery_phrase_view/sub_widgets/mnemonic_table.dart';
-import '../../../home_view/home_view.dart';
-import '../../../wallet_view/transaction_views/tx_v2/transaction_v2_details_view.dart';
import '../../../../providers/global/secure_store_provider.dart';
import '../../../../providers/global/wallets_provider.dart';
import '../../../../themes/stack_colors.dart';
@@ -35,6 +33,9 @@ import '../../../../widgets/desktop/primary_button.dart';
import '../../../../widgets/detail_item.dart';
import '../../../../widgets/rounded_white_container.dart';
import '../../../../widgets/stack_dialog.dart';
+import '../../../add_wallet_views/new_wallet_recovery_phrase_view/sub_widgets/mnemonic_table.dart';
+import '../../../home_view/home_view.dart';
+import '../../../wallet_view/transaction_views/tx_v2/transaction_v2_details_view.dart';
class DeleteWalletRecoveryPhraseView extends ConsumerStatefulWidget {
const DeleteWalletRecoveryPhraseView({
@@ -69,7 +70,6 @@ class _DeleteWalletRecoveryPhraseViewState
late ClipboardInterface _clipboardInterface;
bool _lock = false;
-
void _continuePressed() {
if (_lock) {
return;
diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_warning_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_warning_view.dart
index afb5137b4..9421b251d 100644
--- a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_warning_view.dart
+++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_warning_view.dart
@@ -17,9 +17,11 @@ import '../../../../themes/stack_colors.dart';
import '../../../../utilities/text_styles.dart';
import '../../../../wallets/wallet/impl/bitcoin_frost_wallet.dart';
import '../../../../wallets/wallet/wallet_mixin_interfaces/mnemonic_interface.dart';
+import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart';
import '../../../../widgets/background.dart';
import '../../../../widgets/custom_buttons/app_bar_icon_button.dart';
import '../../../../widgets/rounded_container.dart';
+import 'delete_view_only_wallet_keys_view.dart';
import 'delete_wallet_recovery_phrase_view.dart';
class DeleteWalletWarningView extends ConsumerWidget {
@@ -118,6 +120,7 @@ class DeleteWalletWarningView extends ConsumerWidget {
String keys,
({String config, String keys})? prevGen,
})? frostWalletData;
+ ViewOnlyWalletData? viewOnlyData;
if (wallet is BitcoinFrostWallet) {
final futures = [
@@ -142,18 +145,33 @@ class DeleteWalletWarningView extends ConsumerWidget {
),
);
}
- } else if (wallet is MnemonicInterface) {
- mnemonic = await wallet.getMnemonicAsWords();
+ } else {
+ if (wallet is ViewOnlyOptionInterface &&
+ wallet.isViewOnly) {
+ viewOnlyData = await wallet.getViewOnlyWalletData();
+ } else if (wallet is MnemonicInterface) {
+ mnemonic = await wallet.getMnemonicAsWords();
+ }
}
if (context.mounted) {
- await Navigator.of(context).pushNamed(
- DeleteWalletRecoveryPhraseView.routeName,
- arguments: (
- walletId: walletId,
- mnemonicWords: mnemonic ?? [],
- frostWalletData: frostWalletData,
- ),
- );
+ if (viewOnlyData != null) {
+ await Navigator.of(context).pushNamed(
+ DeleteViewOnlyWalletKeysView.routeName,
+ arguments: (
+ walletId: walletId,
+ data: viewOnlyData,
+ ),
+ );
+ } else {
+ await Navigator.of(context).pushNamed(
+ DeleteWalletRecoveryPhraseView.routeName,
+ arguments: (
+ walletId: walletId,
+ mnemonicWords: mnemonic ?? [],
+ frostWalletData: frostWalletData,
+ ),
+ );
+ }
}
},
child: Text(
diff --git a/lib/pages/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart
index e4ad804dc..bb0cf2037 100644
--- a/lib/pages/wallet_view/wallet_view.dart
+++ b/lib/pages/wallet_view/wallet_view.dart
@@ -50,11 +50,13 @@ 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';
import '../../wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart';
import '../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart';
+import '../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart';
import '../../widgets/background.dart';
import '../../widgets/conditional_parent.dart';
import '../../widgets/custom_buttons/app_bar_icon_button.dart';
@@ -66,6 +68,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 +81,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';
@@ -521,6 +525,10 @@ class _WalletViewState extends ConsumerState {
final prefs = ref.watch(prefsChangeNotifierProvider);
final showExchange = prefs.enableExchange;
+ final wallet = ref.watch(pWallets).getWallet(walletId);
+
+ final viewOnly = wallet is ViewOnlyOptionInterface && wallet.isViewOnly;
+
return ConditionalParent(
condition: _rescanningOnOpen,
builder: (child) {
@@ -1023,38 +1031,39 @@ class _WalletViewState extends ConsumerState {
icon: const FrostSignNavIcon(),
onTap: () => _onFrostSignPressed(context),
),
- WalletNavigationBarItemData(
- label: "Send",
- icon: const SendNavIcon(),
- onTap: () {
- // not sure what this is supposed to accomplish?
- // switch (ref
- // .read(walletBalanceToggleStateProvider.state)
- // .state) {
- // case WalletBalanceToggleState.full:
- // ref
- // .read(publicPrivateBalanceStateProvider.state)
- // .state = "Public";
- // break;
- // case WalletBalanceToggleState.available:
- // ref
- // .read(publicPrivateBalanceStateProvider.state)
- // .state = "Private";
- // break;
- // }
- Navigator.of(context).pushNamed(
- ref.read(pWallets).getWallet(walletId)
- is BitcoinFrostWallet
- ? FrostSendView.routeName
- : SendView.routeName,
- arguments: (
- walletId: walletId,
- coin: coin,
- ),
- );
- },
- ),
- if (Constants.enableExchange &&
+ if (!viewOnly)
+ WalletNavigationBarItemData(
+ label: "Send",
+ icon: const SendNavIcon(),
+ onTap: () {
+ // not sure what this is supposed to accomplish?
+ // switch (ref
+ // .read(walletBalanceToggleStateProvider.state)
+ // .state) {
+ // case WalletBalanceToggleState.full:
+ // ref
+ // .read(publicPrivateBalanceStateProvider.state)
+ // .state = "Public";
+ // break;
+ // case WalletBalanceToggleState.available:
+ // ref
+ // .read(publicPrivateBalanceStateProvider.state)
+ // .state = "Private";
+ // break;
+ // }
+ Navigator.of(context).pushNamed(
+ wallet is BitcoinFrostWallet
+ ? FrostSendView.routeName
+ : SendView.routeName,
+ arguments: (
+ walletId: walletId,
+ coin: coin,
+ ),
+ );
+ },
+ ),
+ if (!viewOnly &&
+ Constants.enableExchange &&
ref.watch(pWalletCoin(walletId)) is! FrostCurrency &&
AppConfig.hasFeature(AppFeature.swap) &&
showExchange)
@@ -1110,12 +1119,7 @@ class _WalletViewState extends ConsumerState {
);
},
),
- if (ref.watch(
- pWallets.select(
- (value) => value.getWallet(widget.walletId)
- is CoinControlInterface,
- ),
- ) &&
+ if (wallet is CoinControlInterface &&
ref.watch(
prefsChangeNotifierProvider.select(
(value) => value.enableCoinControl,
@@ -1134,12 +1138,7 @@ class _WalletViewState extends ConsumerState {
);
},
),
- if (ref.watch(
- pWallets.select(
- (value) =>
- value.getWallet(widget.walletId) is PaynymInterface,
- ),
- ))
+ if (wallet is PaynymInterface)
WalletNavigationBarItemData(
label: "PayNym",
icon: const PaynymNavIcon(),
@@ -1210,12 +1209,7 @@ class _WalletViewState extends ConsumerState {
);
},
),
- if (ref.watch(
- pWallets.select(
- (value) => value.getWallet(widget.walletId)
- is CashFusionInterface,
- ),
- ))
+ if (wallet is CashFusionInterface && !viewOnly)
WalletNavigationBarItemData(
label: "Fusion",
icon: const FusionNavIcon(),
@@ -1226,6 +1220,17 @@ class _WalletViewState extends ConsumerState {
);
},
),
+ if (wallet is LibMoneroWallet && !viewOnly)
+ 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..e3a7b0736
--- /dev/null
+++ b/lib/pages_desktop_specific/churning/desktop_churning_view.dart
@@ -0,0 +1,424 @@
+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,
+ color: Theme.of(context)
+ .extension()!
+ .textSubtitle1,
+ ),
+ 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 in a Monero wallet involves"
+ " sending Monero to oneself in multiple"
+ " transactions, which can enhance privacy"
+ " by making it harder for observers to "
+ "link your transactions. This process"
+ " re-mixes the funds within the network,"
+ " helping obscure transaction history. "
+ "Churning is optional and mainly beneficial"
+ " in scenarios where maximum privacy is"
+ " desired or if you received the Monero from"
+ " a source from which you'd like to disassociate.",
+ 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..44615e244
--- /dev/null
+++ b/lib/pages_desktop_specific/churning/sub_widgets/churning_dialog.dart
@@ -0,0 +1,323 @@
+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.alertCircle,
+ label: "Waiting for balance to unlock ${ref.watch(
+ pChurningService(widget.walletId)
+ .select((s) => s.confirmsInfo),
+ ) ?? ""}",
+ status: ref.watch(
+ pChurningService(widget.walletId)
+ .select((s) => s.waitingForUnlockedBalance),
+ ),
+ ),
+ const SizedBox(
+ height: 12,
+ ),
+ ProgressItem(
+ iconAsset: Assets.svg.churn,
+ 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_summary_table.dart b/lib/pages_desktop_specific/my_stack_view/wallet_summary_table.dart
index 983f01a42..b30ffb17a 100644
--- a/lib/pages_desktop_specific/my_stack_view/wallet_summary_table.dart
+++ b/lib/pages_desktop_specific/my_stack_view/wallet_summary_table.dart
@@ -19,15 +19,19 @@ import '../../providers/providers.dart';
import '../../themes/coin_icon_provider.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/amount/amount.dart';
+import '../../utilities/show_loading.dart';
import '../../utilities/text_styles.dart';
+import '../../utilities/util.dart';
import '../../wallets/crypto_currency/crypto_currency.dart';
import '../../wallets/isar/providers/all_wallets_info_provider.dart';
+import '../../wallets/wallet/intermediate/lib_monero_wallet.dart';
import '../../widgets/breathing.dart';
import '../../widgets/conditional_parent.dart';
import '../../widgets/desktop/desktop_dialog.dart';
import '../../widgets/desktop/desktop_dialog_close_button.dart';
import '../../widgets/dialogs/tor_warning_dialog.dart';
import '../../widgets/rounded_white_container.dart';
+import 'wallet_view/desktop_wallet_view.dart';
class WalletSummaryTable extends ConsumerStatefulWidget {
const WalletSummaryTable({super.key});
@@ -86,10 +90,7 @@ class DesktopWalletSummaryRow extends ConsumerStatefulWidget {
class _DesktopWalletSummaryRowState
extends ConsumerState {
- bool _hovering = false;
-
- void _onPressed() async {
- // Check if Tor is enabled...
+ Future _checkTor() async {
if (ref.read(prefsChangeNotifierProvider).useTor) {
// ... and if the coin supports Tor.
if (!widget.coin.torSupport) {
@@ -106,44 +107,97 @@ class _DesktopWalletSummaryRowState
}
}
}
+ }
- showDialog(
- context: context,
- builder: (_) => DesktopDialog(
- maxHeight: 600,
- maxWidth: 700,
- child: Column(
- children: [
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ bool get goStraightIntoWallet =>
+ Util.isDesktop && widget.walletCount == 1 && !widget.coin.hasTokenSupport;
+
+ bool _buttonLock = false;
+ Future _onPressedSingleWalletDesktop() async {
+ if (_buttonLock) return;
+ _buttonLock = true;
+ try {
+ await _checkTor();
+
+ if (mounted) {
+ final wallet = ref.read(pWallets).wallets.firstWhere(
+ (e) => e.cryptoCurrency.identifier == widget.coin.identifier);
+
+ final Future loadFuture;
+ if (wallet is LibMoneroWallet) {
+ loadFuture =
+ wallet.init().then((value) async => await (wallet).open());
+ } else {
+ loadFuture = wallet.init();
+ }
+ await showLoading(
+ whileFuture: loadFuture,
+ context: context,
+ message: 'Opening ${wallet.info.name}',
+ rootNavigator: Util.isDesktop,
+ );
+
+ if (mounted) {
+ await Navigator.of(context).pushNamed(
+ DesktopWalletView.routeName,
+ arguments: wallet.walletId,
+ );
+ }
+ }
+ } finally {
+ _buttonLock = false;
+ }
+ }
+
+ void _onPressed() async {
+ if (_buttonLock) return;
+ _buttonLock = true;
+ try {
+ // Check if Tor is enabled...
+ await _checkTor();
+
+ if (mounted) {
+ await showDialog(
+ context: context,
+ builder: (_) => DesktopDialog(
+ maxHeight: 600,
+ maxWidth: 700,
+ child: Column(
children: [
- Padding(
- padding: const EdgeInsets.only(left: 32),
- child: Text(
- "${widget.coin.prettyName} (${widget.coin.ticker}) wallets",
- style: STextStyles.desktopH3(context),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(left: 32),
+ child: Text(
+ "${widget.coin.prettyName} (${widget.coin.ticker}) wallets",
+ style: STextStyles.desktopH3(context),
+ ),
+ ),
+ const DesktopDialogCloseButton(),
+ ],
+ ),
+ Expanded(
+ child: Padding(
+ padding: const EdgeInsets.only(
+ left: 32,
+ right: 32,
+ bottom: 32,
+ ),
+ child: WalletsOverview(
+ coin: widget.coin,
+ navigatorState: Navigator.of(context),
+ ),
),
),
- const DesktopDialogCloseButton(),
],
),
- Expanded(
- child: Padding(
- padding: const EdgeInsets.only(
- left: 32,
- right: 32,
- bottom: 32,
- ),
- child: WalletsOverview(
- coin: widget.coin,
- navigatorState: Navigator.of(context),
- ),
- ),
- ),
- ],
- ),
- ),
- );
+ ),
+ );
+ }
+ } finally {
+ _buttonLock = false;
+ }
}
@override
@@ -152,7 +206,8 @@ class _DesktopWalletSummaryRowState
child: RoundedWhiteContainer(
padding: const EdgeInsets.all(20),
hoverColor: Colors.transparent,
- onPressed: _onPressed,
+ onPressed:
+ goStraightIntoWallet ? _onPressedSingleWalletDesktop : _onPressed,
child: Row(
children: [
Expanded(
diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_attention_delete_wallet.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_attention_delete_wallet.dart
index 21d4bfbf5..16032b36b 100644
--- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_attention_delete_wallet.dart
+++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_attention_delete_wallet.dart
@@ -10,18 +10,21 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:tuple/tuple.dart';
+
import '../../../../app_config.dart';
-import 'delete_wallet_keys_popup.dart';
+import '../../../../pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_view_only_wallet_keys_view.dart';
import '../../../../providers/global/wallets_provider.dart';
import '../../../../themes/stack_colors.dart';
import '../../../../utilities/text_styles.dart';
import '../../../../wallets/wallet/wallet_mixin_interfaces/mnemonic_interface.dart';
+import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.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 'package:tuple/tuple.dart';
+import 'delete_wallet_keys_popup.dart';
class DesktopAttentionDeleteWallet extends ConsumerStatefulWidget {
const DesktopAttentionDeleteWallet({
@@ -114,6 +117,58 @@ class _DesktopAttentionDeleteWallet
onPressed: () async {
final wallet =
ref.read(pWallets).getWallet(widget.walletId);
+
+ if (wallet is ViewOnlyOptionInterface &&
+ wallet.isViewOnly) {
+ final data = await wallet.getViewOnlyWalletData();
+ if (context.mounted) {
+ await Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (builder) => DesktopDialog(
+ maxWidth: 614,
+ maxHeight: double.infinity,
+ child: Column(
+ children: [
+ Row(
+ mainAxisAlignment:
+ MainAxisAlignment.spaceBetween,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(
+ left: 32,
+ ),
+ child: Text(
+ "Wallet keys",
+ style: STextStyles.desktopH3(
+ context,
+ ),
+ ),
+ ),
+ DesktopDialogCloseButton(
+ onPressedOverride: () {
+ Navigator.of(
+ context,
+ rootNavigator: true,
+ ).pop();
+ },
+ ),
+ ],
+ ),
+ Padding(
+ padding: const EdgeInsets.all(32),
+ child: DeleteViewOnlyWalletKeysView(
+ walletId: widget.walletId,
+ data: data,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+ } else
+
// TODO: [prio=med] handle other types wallet deletion
// All wallets currently are mnemonic based
if (wallet is MnemonicInterface) {
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..470c1f89b 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
@@ -39,12 +39,14 @@ import '../../../../wallets/wallet/wallet_mixin_interfaces/cash_fusion_interface
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/paynym_interface.dart';
+import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart';
import '../../../../widgets/custom_loading_overlay.dart';
import '../../../../widgets/desktop/desktop_dialog.dart';
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 +94,7 @@ class _DesktopWalletFeaturesState extends ConsumerState {
onOrdinalsPressed: _onOrdinalsPressed,
onMonkeyPressed: _onMonkeyPressed,
onFusionPressed: _onFusionPressed,
+ onChurnPressed: _onChurnPressed,
),
);
}
@@ -348,6 +351,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);
@@ -369,9 +381,12 @@ class _DesktopWalletFeaturesState extends ConsumerState {
wallet is OrdinalsInterface ||
wallet is CashFusionInterface;
+ final isViewOnly = wallet is ViewOnlyOptionInterface && wallet.isViewOnly;
+
return Row(
children: [
- if (Constants.enableExchange &&
+ if (!isViewOnly &&
+ Constants.enableExchange &&
AppConfig.hasFeature(AppFeature.swap) &&
showExchange)
SecondaryButton(
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..95147a002 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';
@@ -30,6 +31,7 @@ import '../../../../../wallets/wallet/wallet_mixin_interfaces/ordinals_interface
import '../../../../../wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart';
import '../../../../../wallets/wallet/wallet_mixin_interfaces/rbf_interface.dart';
import '../../../../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart';
+import '../../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart';
import '../../../../../widgets/custom_buttons/draggable_switch_button.dart';
import '../../../../../widgets/desktop/desktop_dialog.dart';
import '../../../../../widgets/desktop/desktop_dialog_close_button.dart';
@@ -48,6 +50,7 @@ class MoreFeaturesDialog extends ConsumerStatefulWidget {
required this.onOrdinalsPressed,
required this.onMonkeyPressed,
required this.onFusionPressed,
+ required this.onChurnPressed,
});
final String walletId;
@@ -58,6 +61,7 @@ class MoreFeaturesDialog extends ConsumerStatefulWidget {
final VoidCallback? onOrdinalsPressed;
final VoidCallback? onMonkeyPressed;
final VoidCallback? onFusionPressed;
+ final VoidCallback? onChurnPressed;
@override
ConsumerState createState() => _MoreFeaturesDialogState();
@@ -235,6 +239,8 @@ class _MoreFeaturesDialogState extends ConsumerState {
),
);
+ final isViewOnly = wallet is ViewOnlyOptionInterface && wallet.isViewOnly;
+
return DesktopDialog(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -254,7 +260,7 @@ class _MoreFeaturesDialogState extends ConsumerState {
const DesktopDialogCloseButton(),
],
),
- if (wallet.info.coin is Firo)
+ if (!isViewOnly && wallet.info.coin is Firo)
_MoreFeaturesItem(
label: "Anonymize funds",
detail: "Anonymize funds",
@@ -297,13 +303,20 @@ class _MoreFeaturesDialogState extends ConsumerState {
iconAsset: Assets.svg.monkey,
onPressed: () async => widget.onMonkeyPressed?.call(),
),
- if (wallet is CashFusionInterface)
+ if (!isViewOnly && wallet is CashFusionInterface)
_MoreFeaturesItem(
label: "Fusion",
detail: "Decentralized mixing protocol",
iconAsset: Assets.svg.cashFusion,
onPressed: () async => widget.onFusionPressed?.call(),
),
+ if (!isViewOnly && 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/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart
index 092f9884f..622630c4a 100644
--- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart
+++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart
@@ -10,20 +10,22 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
+
import '../../../../frost_route_generator.dart';
import '../../../../pages/send_view/frost_ms/frost_send_view.dart';
import '../../../../pages/wallet_view/transaction_views/tx_v2/transaction_v2_list.dart';
-import '../../my_stack_view.dart';
-import 'desktop_receive.dart';
-import 'desktop_send.dart';
-import 'desktop_token_send.dart';
import '../../../../providers/global/wallets_provider.dart';
import '../../../../wallets/crypto_currency/crypto_currency.dart';
import '../../../../wallets/wallet/impl/bitcoin_frost_wallet.dart';
+import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart';
import '../../../../widgets/custom_tab_view.dart';
import '../../../../widgets/desktop/secondary_button.dart';
import '../../../../widgets/frost_scaffold.dart';
import '../../../../widgets/rounded_white_container.dart';
+import '../../my_stack_view.dart';
+import 'desktop_receive.dart';
+import 'desktop_send.dart';
+import 'desktop_token_send.dart';
class MyWallet extends ConsumerStatefulWidget {
const MyWallet({
@@ -48,6 +50,7 @@ class _MyWalletState extends ConsumerState {
late final bool isEth;
late final CryptoCurrency coin;
late final bool isFrost;
+ late final bool isViewOnly;
@override
void initState() {
@@ -60,11 +63,34 @@ class _MyWalletState extends ConsumerState {
titles.add("Transactions");
}
+ isViewOnly = wallet is ViewOnlyOptionInterface && wallet.isViewOnly;
+ if (isViewOnly) {
+ titles.remove("Receive");
+ }
+
super.initState();
}
@override
Widget build(BuildContext context) {
+ if (isViewOnly) {
+ return ListView(
+ primary: false,
+ children: [
+ RoundedWhiteContainer(
+ padding: EdgeInsets.zero,
+ child: Padding(
+ padding: const EdgeInsets.all(20),
+ child: DesktopReceive(
+ walletId: widget.walletId,
+ contractAddress: widget.contractAddress,
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+
return ListView(
primary: false,
children: [
diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/unlock_wallet_keys_desktop.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/unlock_wallet_keys_desktop.dart
index 3ae249dfb..3db6866a8 100644
--- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/unlock_wallet_keys_desktop.dart
+++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/unlock_wallet_keys_desktop.dart
@@ -26,6 +26,7 @@ import '../../../../wallets/wallet/impl/bitcoin_frost_wallet.dart';
import '../../../../wallets/wallet/intermediate/lib_monero_wallet.dart';
import '../../../../wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart';
import '../../../../wallets/wallet/wallet_mixin_interfaces/mnemonic_interface.dart';
+import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart';
import '../../../../widgets/desktop/desktop_dialog.dart';
import '../../../../widgets/desktop/desktop_dialog_close_button.dart';
import '../../../../widgets/desktop/primary_button.dart';
@@ -100,7 +101,11 @@ class _UnlockWalletKeysDesktopState
throw Exception("FIXME ~= see todo in code");
}
} else {
- words = await wallet.getMnemonicAsWords();
+ if (wallet is ViewOnlyOptionInterface) {
+ // TODO: is something needed here?
+ } else {
+ words = await wallet.getMnemonicAsWords();
+ }
}
KeyDataInterface? keyData;
@@ -347,7 +352,11 @@ class _UnlockWalletKeysDesktopState
throw Exception("FIXME ~= see todo in code");
}
} else {
- words = await wallet.getMnemonicAsWords();
+ if (wallet is ViewOnlyOptionInterface) {
+ // TODO: is something needed here?
+ } else {
+ words = await wallet.getMnemonicAsWords();
+ }
}
KeyDataInterface? keyData;
diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_keys_desktop_popup.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_keys_desktop_popup.dart
index f1bc2d9f6..e80e42158 100644
--- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_keys_desktop_popup.dart
+++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_keys_desktop_popup.dart
@@ -182,17 +182,18 @@ class WalletKeysDesktopPopup extends ConsumerWidget {
: keyData != null
? CustomTabView(
titles: [
- "Mnemonic",
+ if (words.isNotEmpty) "Mnemonic",
if (keyData is XPrivData) "XPriv(s)",
if (keyData is CWKeyData) "Keys",
],
children: [
- Padding(
- padding: const EdgeInsets.only(top: 16),
- child: _Mnemonic(
- words: words,
+ if (words.isNotEmpty)
+ Padding(
+ padding: const EdgeInsets.only(top: 16),
+ child: _Mnemonic(
+ words: words,
+ ),
),
- ),
if (keyData is XPrivData)
WalletXPrivs(
xprivData: keyData as XPrivData,
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..41aec3834 100644
--- a/lib/route_generator.dart
+++ b/lib/route_generator.dart
@@ -37,6 +37,7 @@ import 'pages/add_wallet_views/new_wallet_options/new_wallet_options_view.dart';
import 'pages/add_wallet_views/new_wallet_recovery_phrase_view/new_wallet_recovery_phrase_view.dart';
import 'pages/add_wallet_views/new_wallet_recovery_phrase_warning_view/new_wallet_recovery_phrase_warning_view.dart';
import 'pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart';
+import 'pages/add_wallet_views/restore_wallet_view/restore_view_only_wallet_view.dart';
import 'pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart';
import 'pages/add_wallet_views/select_wallet_for_token_view.dart';
import 'pages/add_wallet_views/verify_recovery_phrase_view/verify_recovery_phrase_view.dart';
@@ -52,6 +53,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';
@@ -128,6 +131,7 @@ import 'pages/settings_views/wallet_settings_view/wallet_backup_views/wallet_bac
import 'pages/settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart';
import 'pages/settings_views/wallet_settings_view/wallet_settings_view.dart';
import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/change_representative_view.dart';
+import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_view_only_wallet_keys_view.dart';
import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_recovery_phrase_view.dart';
import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_warning_view.dart';
import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/lelantus_settings_view.dart';
@@ -155,6 +159,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';
@@ -200,6 +205,7 @@ import 'wallets/crypto_currency/intermediate/frost_currency.dart';
import 'wallets/models/tx_data.dart';
import 'wallets/wallet/wallet.dart';
import 'wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart';
+import 'wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart';
import 'widgets/choose_coin_view.dart';
import 'widgets/frost_scaffold.dart';
@@ -779,6 +785,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 +827,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,
@@ -1474,6 +1522,28 @@ class RouteGenerator {
}
return _routeError("${settings.name} invalid args: ${args.toString()}");
+ case RestoreViewOnlyWalletView.routeName:
+ if (args is ({
+ String walletName,
+ CryptoCurrency coin,
+ DateTime? restoreFromDate,
+ bool enableLelantusScanning,
+ })) {
+ return getRoute(
+ shouldUseMaterialRoute: useMaterialPageRoute,
+ builder: (_) => RestoreViewOnlyWalletView(
+ walletName: args.walletName,
+ coin: args.coin,
+ restoreFromDate: args.restoreFromDate,
+ enableLelantusScanning: args.enableLelantusScanning,
+ ),
+ settings: RouteSettings(
+ name: settings.name,
+ ),
+ );
+ }
+ return _routeError("${settings.name} invalid args: ${args.toString()}");
+
case NewWalletRecoveryPhraseView.routeName:
if (args is Tuple2>) {
return getRoute(
@@ -1882,6 +1952,21 @@ class RouteGenerator {
}
return _routeError("${settings.name} invalid args: ${args.toString()}");
+ case DeleteViewOnlyWalletKeysView.routeName:
+ if (args is ({String walletId, ViewOnlyWalletData data})) {
+ return getRoute(
+ shouldUseMaterialRoute: useMaterialPageRoute,
+ builder: (_) => DeleteViewOnlyWalletKeysView(
+ data: args.data,
+ walletId: args.walletId,
+ ),
+ settings: RouteSettings(
+ name: settings.name,
+ ),
+ );
+ }
+ return _routeError("${settings.name} invalid args: ${args.toString()}");
+
// exchange steps
case Step1View.routeName:
diff --git a/lib/services/churning_service.dart b/lib/services/churning_service.dart
new file mode 100644
index 000000000..0aecefe08
--- /dev/null
+++ b/lib/services/churning_service.dart
@@ -0,0 +1,207 @@
+import 'dart:async';
+import 'dart:math';
+
+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;
+ }
+ }
+
+ String? confirmsInfo;
+ Future _updateConfirmsInfo() async {
+ final currentHeight = wallet.currentKnownChainHeight;
+ if (currentHeight < 1) {
+ return;
+ }
+
+ final outputs = await csWallet.getOutputs(refresh: true);
+ final required = wallet.cryptoCurrency.minConfirms;
+
+ int lowestNumberOfConfirms = required;
+
+ for (final output in outputs.where((e) => !e.isFrozen && !e.spent)) {
+ final confirms = currentHeight - output.height;
+
+ lowestNumberOfConfirms = min(lowestNumberOfConfirms, confirms);
+ }
+
+ final bool shouldNotify;
+ if (lowestNumberOfConfirms == required) {
+ shouldNotify = confirmsInfo != null;
+ confirmsInfo = null;
+ } else {
+ final prev = confirmsInfo;
+ confirmsInfo = "($lowestNumberOfConfirms/$required)";
+ shouldNotify = confirmsInfo != prev;
+ }
+
+ if (_running && _timerRunning && shouldNotify) {
+ notifyListeners();
+ }
+ }
+
+ Timer? _confirmsTimer;
+ bool _timerRunning = false;
+ void _stopConfirmsTimer() {
+ _timerRunning = false;
+ _confirmsTimer?.cancel();
+ confirmsInfo = null;
+ _confirmsTimer = null;
+ }
+
+ void _startConfirmsTimer() {
+ _confirmsTimer?.cancel();
+ _confirmsTimer = Timer.periodic(
+ const Duration(seconds: 5),
+ (_) => _updateConfirmsInfo(),
+ );
+ }
+
+ 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 {
+ _stopConfirmsTimer();
+ 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) {
+ _startConfirmsTimer();
+ 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/wallets/crypto_currency/interfaces/view_only_option_currency_interface.dart b/lib/wallets/crypto_currency/interfaces/view_only_option_currency_interface.dart
new file mode 100644
index 000000000..dec5c016b
--- /dev/null
+++ b/lib/wallets/crypto_currency/interfaces/view_only_option_currency_interface.dart
@@ -0,0 +1,5 @@
+import '../crypto_currency.dart';
+
+mixin ViewOnlyOptionCurrencyInterface on CryptoCurrency {
+ //
+}
diff --git a/lib/wallets/crypto_currency/intermediate/cryptonote_currency.dart b/lib/wallets/crypto_currency/intermediate/cryptonote_currency.dart
index 496336235..319a501ac 100644
--- a/lib/wallets/crypto_currency/intermediate/cryptonote_currency.dart
+++ b/lib/wallets/crypto_currency/intermediate/cryptonote_currency.dart
@@ -1,7 +1,9 @@
import '../../../models/isar/models/blockchain_data/address.dart';
import '../crypto_currency.dart';
+import '../interfaces/view_only_option_currency_interface.dart';
-abstract class CryptonoteCurrency extends CryptoCurrency {
+abstract class CryptonoteCurrency extends CryptoCurrency
+ with ViewOnlyOptionCurrencyInterface {
CryptonoteCurrency(super.network);
@override
diff --git a/lib/wallets/isar/models/wallet_info.dart b/lib/wallets/isar/models/wallet_info.dart
index 6a7a9b54c..3e296a3a0 100644
--- a/lib/wallets/isar/models/wallet_info.dart
+++ b/lib/wallets/isar/models/wallet_info.dart
@@ -117,6 +117,10 @@ class WalletInfo implements IsarId {
? {}
: Map.from(jsonDecode(otherDataJsonString!) as Map);
+ @ignore
+ bool get isViewOnly =>
+ otherData[WalletInfoKeys.isViewOnlyKey] as bool? ?? false;
+
Future isMnemonicVerified(Isar isar) async =>
(await isar.walletInfoMeta.where().walletIdEqualTo(walletId).findFirst())
?.isMnemonicVerified ==
@@ -512,4 +516,5 @@ abstract class WalletInfoKeys {
"firoSparkCacheSetTimestampCacheKey";
static const String enableOptInRbf = "enableOptInRbfKey";
static const String reuseAddress = "reuseAddressKey";
+ static const String isViewOnlyKey = "isViewOnlyKey";
}
diff --git a/lib/wallets/wallet/impl/monero_wallet.dart b/lib/wallets/wallet/impl/monero_wallet.dart
index 0bc11c3e3..bcec63851 100644
--- a/lib/wallets/wallet/impl/monero_wallet.dart
+++ b/lib/wallets/wallet/impl/monero_wallet.dart
@@ -97,6 +97,22 @@ class MoneroWallet extends LibMoneroWallet {
restoreHeight: height,
);
+ @override
+ Future getRestoredFromViewKeyWallet({
+ required String path,
+ required String password,
+ required String address,
+ required String privateViewKey,
+ int height = 0,
+ }) async =>
+ lib_monero.MoneroWallet.createViewOnlyWallet(
+ path: path,
+ password: password,
+ address: address,
+ viewKey: privateViewKey,
+ restoreHeight: height,
+ );
+
@override
void invalidSeedLengthCheck(int length) {
if (length != 25 && length != 16) {
diff --git a/lib/wallets/wallet/impl/wownero_wallet.dart b/lib/wallets/wallet/impl/wownero_wallet.dart
index afcfee319..a33bd2da7 100644
--- a/lib/wallets/wallet/impl/wownero_wallet.dart
+++ b/lib/wallets/wallet/impl/wownero_wallet.dart
@@ -134,9 +134,25 @@ class WowneroWallet extends LibMoneroWallet {
restoreHeight: height,
);
+ @override
+ Future getRestoredFromViewKeyWallet({
+ required String path,
+ required String password,
+ required String address,
+ required String privateViewKey,
+ int height = 0,
+ }) async =>
+ lib_monero.WowneroWallet.createViewOnlyWallet(
+ path: path,
+ password: password,
+ address: address,
+ viewKey: privateViewKey,
+ restoreHeight: height,
+ );
+
@override
void invalidSeedLengthCheck(int length) {
- if (!(length == 14 || length == 25)) {
+ if (!(length == 14 || length == 16 || length == 25)) {
throw Exception("Invalid wownero mnemonic length found: $length");
}
}
diff --git a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart
index a8029baa4..5e7d2a8dd 100644
--- a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart
+++ b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart
@@ -36,10 +36,12 @@ import '../../isar/models/wallet_info.dart';
import '../../models/tx_data.dart';
import '../wallet.dart';
import '../wallet_mixin_interfaces/multi_address_interface.dart';
+import '../wallet_mixin_interfaces/view_only_option_interface.dart';
import 'cryptonote_wallet.dart';
abstract class LibMoneroWallet
- extends CryptonoteWallet implements MultiAddressInterface {
+ extends CryptonoteWallet
+ implements MultiAddressInterface, ViewOnlyOptionInterface {
@override
int get isarTransactionVersion => 2;
@@ -139,6 +141,14 @@ abstract class LibMoneroWallet
int height = 0,
});
+ Future getRestoredFromViewKeyWallet({
+ required String path,
+ required String password,
+ required String address,
+ required String privateViewKey,
+ int height = 0,
+ });
+
void invalidSeedLengthCheck(int length);
bool walletExists(String path);
@@ -333,6 +343,11 @@ abstract class LibMoneroWallet
return;
}
+ if (isViewOnly) {
+ await recoverViewOnly();
+ return;
+ }
+
await refreshMutex.protect(() async {
final mnemonic = await getMnemonic();
final seedLength = mnemonic.trim().split(" ").length;
@@ -1284,6 +1299,102 @@ abstract class LibMoneroWallet
}
}
+ // ============== View only ==================================================
+
+ @override
+ bool get isViewOnly => info.isViewOnly;
+
+ @override
+ Future recoverViewOnly() async {
+ await refreshMutex.protect(() async {
+ final jsonEncodedString = await secureStorageInterface.read(
+ key: Wallet.getViewOnlyWalletDataSecStoreKey(
+ walletId: walletId,
+ ),
+ );
+
+ final data = ViewOnlyWalletData.fromJsonEncodedString(jsonEncodedString!);
+
+ try {
+ final height = max(info.restoreHeight, 0);
+
+ await info.updateRestoreHeight(
+ newRestoreHeight: height,
+ isar: mainDB.isar,
+ );
+
+ final String name = walletId;
+
+ final path = await pathForWallet(
+ name: name,
+ type: compatType,
+ );
+
+ final password = generatePassword();
+ await secureStorageInterface.write(
+ key: lib_monero_compat.libMoneroWalletPasswordKey(walletId),
+ value: password,
+ );
+ final wallet = await getRestoredFromViewKeyWallet(
+ path: path,
+ password: password,
+ address: data.address!,
+ privateViewKey: data.privateViewKey!,
+ height: height,
+ );
+
+ if (libMoneroWallet != null) {
+ await exit();
+ }
+
+ libMoneroWallet = wallet;
+
+ _setListener();
+
+ final newReceivingAddress = await getCurrentReceivingAddress() ??
+ Address(
+ walletId: walletId,
+ derivationIndex: 0,
+ derivationPath: null,
+ value: wallet.getAddress().value,
+ publicKey: [],
+ type: AddressType.cryptonote,
+ subType: AddressSubType.receiving,
+ );
+
+ await mainDB.updateOrPutAddresses([newReceivingAddress]);
+ await info.updateReceivingAddress(
+ newAddress: newReceivingAddress.value,
+ isar: mainDB.isar,
+ );
+
+ await updateNode();
+ _setListener();
+
+ unawaited(libMoneroWallet?.rescanBlockchain());
+ libMoneroWallet?.startSyncing();
+
+ // await save();
+ libMoneroWallet?.startListeners();
+ libMoneroWallet?.startAutoSaving();
+ } catch (e, s) {
+ Logging.instance.log(
+ "Exception rethrown from recoverViewOnly(): $e\n$s",
+ level: LogLevel.Error,
+ );
+ rethrow;
+ }
+ });
+ }
+
+ @override
+ Future getViewOnlyWalletData() async {
+ return ViewOnlyWalletData(
+ address: libMoneroWallet!.getAddress().value,
+ privateViewKey: libMoneroWallet!.getPrivateViewKey(),
+ );
+ }
+
// ============== Private ====================================================
StreamSubscription? _torStatusListener;
diff --git a/lib/wallets/wallet/wallet.dart b/lib/wallets/wallet/wallet.dart
index 7e7384267..4f69b6056 100644
--- a/lib/wallets/wallet/wallet.dart
+++ b/lib/wallets/wallet/wallet.dart
@@ -54,6 +54,7 @@ import 'wallet_mixin_interfaces/multi_address_interface.dart';
import 'wallet_mixin_interfaces/paynym_interface.dart';
import 'wallet_mixin_interfaces/private_key_interface.dart';
import 'wallet_mixin_interfaces/spark_interface.dart';
+import 'wallet_mixin_interfaces/view_only_option_interface.dart';
abstract class Wallet {
// default to Transaction class. For TransactionV2 set to 2
@@ -145,7 +146,13 @@ abstract class Wallet {
String? mnemonic,
String? mnemonicPassphrase,
String? privateKey,
+ ViewOnlyWalletData? viewOnlyData,
}) async {
+ // TODO: rework soon?
+ if (walletInfo.isViewOnly && viewOnlyData == null) {
+ throw Exception("Missing view key while creating view only wallet!");
+ }
+
final Wallet wallet = await _construct(
walletInfo: walletInfo,
mainDB: mainDB,
@@ -154,7 +161,12 @@ abstract class Wallet {
prefs: prefs,
);
- if (wallet is MnemonicInterface) {
+ if (wallet is ViewOnlyOptionInterface) {
+ await secureStorageInterface.write(
+ key: getViewOnlyWalletDataSecStoreKey(walletId: walletInfo.walletId),
+ value: viewOnlyData!.toJsonEncodedString(),
+ );
+ } else if (wallet is MnemonicInterface) {
if (wallet is CryptonoteWallet) {
// currently a special case due to the xmr/wow libraries handling their
// own mnemonic generation on new wallet creation
@@ -279,6 +291,12 @@ abstract class Wallet {
}) =>
"${walletId}_privateKey";
+ // secure storage key
+ static String getViewOnlyWalletDataSecStoreKey({
+ required String walletId,
+ }) =>
+ "${walletId}_viewOnlyWalletData";
+
//============================================================================
// ========== Private ========================================================
diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart
new file mode 100644
index 000000000..6e12f02cc
--- /dev/null
+++ b/lib/wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart
@@ -0,0 +1,34 @@
+import 'dart:convert';
+
+import '../../crypto_currency/interfaces/view_only_option_currency_interface.dart';
+import '../wallet.dart';
+
+class ViewOnlyWalletData {
+ final String? address;
+ final String? privateViewKey;
+
+ ViewOnlyWalletData({required this.address, required this.privateViewKey});
+
+ factory ViewOnlyWalletData.fromJsonEncodedString(String jsonEncodedString) {
+ final map = jsonDecode(jsonEncodedString) as Map;
+ final json = Map.from(map);
+ return ViewOnlyWalletData(
+ address: json["address"] as String?,
+ privateViewKey: json["privateViewKey"] as String?,
+ );
+ }
+
+ String toJsonEncodedString() => jsonEncode({
+ "address": address,
+ "privateViewKey": privateViewKey,
+ });
+}
+
+mixin ViewOnlyOptionInterface
+ on Wallet {
+ bool get isViewOnly;
+
+ Future recoverViewOnly();
+
+ Future getViewOnlyWalletData();
+}
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/stack_text_field.dart b/lib/widgets/stack_text_field.dart
index c64c6a5e8..318b59119 100644
--- a/lib/widgets/stack_text_field.dart
+++ b/lib/widgets/stack_text_field.dart
@@ -9,10 +9,15 @@
*/
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import '../themes/stack_colors.dart';
+import '../utilities/constants.dart';
import '../utilities/text_styles.dart';
import '../utilities/util.dart';
+import 'icon_widgets/clipboard_icon.dart';
+import 'icon_widgets/x_icon.dart';
+import 'textfield_icon_button.dart';
InputDecoration standardInputDecoration(
String? labelText,
@@ -52,3 +57,113 @@ InputDecoration standardInputDecoration(
focusedErrorBorder: InputBorder.none,
);
}
+
+class FullTextField extends StatefulWidget {
+ const FullTextField({
+ super.key,
+ this.controller,
+ this.focusNode,
+ required this.label,
+ this.onChanged,
+ });
+
+ final String label;
+
+ final TextEditingController? controller;
+ final FocusNode? focusNode;
+
+ final void Function(String)? onChanged;
+
+ @override
+ State createState() => _FullTextFieldState();
+}
+
+class _FullTextFieldState extends State {
+ late final TextEditingController controller;
+ late final FocusNode focusNode;
+
+ bool _hasValue = false;
+
+ @override
+ void initState() {
+ super.initState();
+ controller = widget.controller ?? TextEditingController();
+ focusNode = widget.focusNode ?? FocusNode();
+ }
+
+ @override
+ void dispose() {
+ if (widget.controller == null) {
+ controller.dispose();
+ }
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return ClipRRect(
+ borderRadius: BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ ),
+ child: TextField(
+ controller: controller,
+ autocorrect: false,
+ enableSuggestions: false,
+ onChanged: (newValue) {
+ widget.onChanged?.call(newValue);
+ },
+ focusNode: focusNode,
+ style: STextStyles.field(context),
+ decoration: standardInputDecoration(
+ widget.label,
+ focusNode,
+ context,
+ ).copyWith(
+ contentPadding: const EdgeInsets.only(
+ left: 16,
+ top: 6,
+ bottom: 8,
+ right: 5,
+ ),
+ suffixIcon: Padding(
+ padding: controller.text.isEmpty
+ ? const EdgeInsets.only(right: 8)
+ : const EdgeInsets.only(right: 0),
+ child: UnconstrainedBox(
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceAround,
+ children: [
+ TextFieldIconButton(
+ onTap: () async {
+ if (_hasValue) {
+ controller.text = "";
+
+ setState(() {
+ _hasValue = false;
+ });
+ } else {
+ final data =
+ await Clipboard.getData(Clipboard.kTextPlain);
+ if (data?.text != null && data!.text!.isNotEmpty) {
+ final content = data.text!.trim();
+
+ controller.text = content;
+ setState(() {
+ _hasValue = content.isNotEmpty;
+ });
+ }
+ }
+
+ widget.onChanged?.call(controller.text);
+ },
+ child: _hasValue ? const XIcon() : const ClipboardIcon(),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
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