Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 101 additions & 4 deletions lib/pages/send_view/confirm_transaction_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import '../../notifications/show_flush_bar.dart';
import '../../pages_desktop_specific/coin_control/desktop_coin_control_use_dialog.dart';
import '../../pages_desktop_specific/my_stack_view/wallet_view/desktop_wallet_view.dart';
import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart';
import '../../providers/global/address_book_service_provider.dart';
import '../../providers/providers.dart';
import '../../providers/wallet/public_private_balance_state_provider.dart';
import '../../route_generator.dart';
Expand Down Expand Up @@ -66,8 +67,10 @@ import '../../widgets/textfield_icon_button.dart';
import '../../wl_gen/interfaces/libepiccash_interface.dart';
import '../pinpad_views/lock_screen_view.dart';
import '../wallet_view/wallet_view.dart';
import 'save_recipient.dart';
import 'sub_widgets/epic_slatepack_dialog.dart';
import 'sub_widgets/mwc_slatepack_dialog.dart';
import 'sub_widgets/save_recipient_controls.dart';
import 'sub_widgets/sending_transaction_dialog.dart';

class ConfirmTransactionView extends ConsumerStatefulWidget {
Expand All @@ -80,6 +83,7 @@ class ConfirmTransactionView extends ConsumerStatefulWidget {
this.isTradeTransaction = false,
this.isPaynymTransaction = false,
this.isPaynymNotificationTransaction = false,
this.isRbfTransaction = false,
this.isTokenTx = false,
this.onSuccessInsteadOfRouteOnSuccess,
});
Expand All @@ -92,6 +96,7 @@ class ConfirmTransactionView extends ConsumerStatefulWidget {
final bool isTradeTransaction;
final bool isPaynymTransaction;
final bool isPaynymNotificationTransaction;
final bool isRbfTransaction;
final bool isTokenTx;
final VoidCallback? onSuccessInsteadOfRouteOnSuccess;
final VoidCallback onSuccess;
Expand All @@ -107,6 +112,11 @@ class _ConfirmTransactionViewState
late final String routeOnSuccessName;
late final bool isDesktop;

late final SaveRecipientOption _saveRecipient;

late final FocusNode _saveRecipientFocusNode;
late final TextEditingController saveRecipientNameController;

late final FocusNode _noteFocusNode;
late final TextEditingController noteController;

Expand Down Expand Up @@ -150,7 +160,9 @@ class _ConfirmTransactionViewState
}

/// Handle MWC slatepack creation for manual exchange.
Future<void> _handleMwcSlatepackCreation(
///
/// Returns whether the slatepack was created.
Future<bool> _handleMwcSlatepackCreation(
BuildContext context,
MimblewimblecoinWallet wallet,
) async {
Expand Down Expand Up @@ -203,6 +215,8 @@ class _ConfirmTransactionViewState
}
}
}

return true;
} catch (e, s) {
Logging.instance.e('Failed to create MWC slatepack: $e\n$s');

Expand All @@ -228,11 +242,15 @@ class _ConfirmTransactionViewState
),
);
}

return false;
}
}

/// Handle Epic Cash slate creation for manual exchange.
Future<void> _handleEpicSlatepackCreation(
///
/// Returns whether the slate was created.
Future<bool> _handleEpicSlatepackCreation(
BuildContext context,
EpiccashWallet wallet,
) async {
Expand Down Expand Up @@ -282,6 +300,8 @@ class _ConfirmTransactionViewState
}
}
}

return true;
} catch (e, s) {
Logging.instance.e('Failed to create Epic Cash slate: $e\n$s');

Expand All @@ -307,6 +327,39 @@ class _ConfirmTransactionViewState
),
);
}

return false;
}
}

Future<void> _saveRecipientAfterSend(String coinIdentifier) async {
final address = _saveRecipient.addressToSave;
if (address == null) {
return;
}

try {
final addressBookService = ref.read(addressBookServiceProvider);
final outcome = await saveRecipient(
address: address,
coinIdentifier: coinIdentifier,
name: saveRecipientNameController.text,
existingContacts: addressBookService.contacts,
addContact: addressBookService.addContact,
);
if (outcome.result == SaveRecipientResult.failed) {
Logging.instance.w(
'Transaction sent, but recipient could not be saved',
error: outcome.error,
stackTrace: outcome.stackTrace,
);
}
} catch (error, stackTrace) {
Logging.instance.w(
'Transaction sent, but recipient could not be saved',
error: error,
stackTrace: stackTrace,
);
}
}

Expand Down Expand Up @@ -397,10 +450,13 @@ class _ConfirmTransactionViewState

if (transactionMethod == 'slatepack') {
// Handle slatepack creation instead of direct send.
await _handleMwcSlatepackCreation(
final created = await _handleMwcSlatepackCreation(
context,
wallet as MimblewimblecoinWallet,
);
if (created) {
await _saveRecipientAfterSend(coin.identifier);
}
closeSendingDialog();
return; // Exit early, don't continue with normal transaction flow.
} else {
Expand All @@ -421,10 +477,13 @@ class _ConfirmTransactionViewState

if (epicTransactionMethod == 'slatepack') {
// Handle slatepack creation instead of direct send.
await _handleEpicSlatepackCreation(
final created = await _handleEpicSlatepackCreation(
context,
wallet as EpiccashWallet,
);
if (created) {
await _saveRecipientAfterSend(coin.identifier);
}
closeSendingDialog();
return; // Exit early, don't continue with normal transaction flow.
} else {
Expand Down Expand Up @@ -467,6 +526,8 @@ class _ConfirmTransactionViewState
);
}

await _saveRecipientAfterSend(coin.identifier);

if (widget.isTokenTx) {
if (wallet is SolanaWallet) {
unawaited(ref.read(pCurrentSolanaTokenWallet)!.refresh());
Expand Down Expand Up @@ -589,6 +650,17 @@ class _ConfirmTransactionViewState
routeOnSuccessName =
widget.routeOnSuccessName ??
(Util.isDesktop ? DesktopWalletView.routeName : WalletView.routeName);
_saveRecipient = SaveRecipientOption(
address: savableRecipientAddress(
txData: widget.txData,
isTradeTransaction: widget.isTradeTransaction,
isPaynymTransaction: widget.isPaynymTransaction,
isPaynymNotificationTransaction: widget.isPaynymNotificationTransaction,
isRbfTransaction: widget.isRbfTransaction,
),
);
_saveRecipientFocusNode = FocusNode();
saveRecipientNameController = TextEditingController();
_noteFocusNode = FocusNode();
noteController = TextEditingController();
noteController.text = widget.txData.note ?? "";
Expand All @@ -602,9 +674,11 @@ class _ConfirmTransactionViewState

@override
void dispose() {
saveRecipientNameController.dispose();
noteController.dispose();
onChainNoteController.dispose();

_saveRecipientFocusNode.dispose();
_noteFocusNode.dispose();
_onChainNoteFocusNode.dispose();
super.dispose();
Expand All @@ -613,6 +687,7 @@ class _ConfirmTransactionViewState
@override
Widget build(BuildContext context) {
final coin = ref.watch(pWalletCoin(walletId));
final canSaveRecipient = _saveRecipient.isOffered;

final String unit;
final wallet = ref.watch(pWallets).getWallet(walletId);
Expand Down Expand Up @@ -897,6 +972,18 @@ class _ConfirmTransactionViewState
],
),
),
if (canSaveRecipient) const SizedBox(height: 12),
if (canSaveRecipient)
RoundedWhiteContainer(
child: SaveRecipientControls(
enabled: _saveRecipient.enabled,
isDesktop: false,
onChanged: (value) =>
setState(() => _saveRecipient.enabled = value),
controller: saveRecipientNameController,
focusNode: _saveRecipientFocusNode,
),
),
],
),
if (isDesktop)
Expand Down Expand Up @@ -1366,6 +1453,16 @@ class _ConfirmTransactionViewState
),
),
const SizedBox(height: 20),
if (canSaveRecipient)
SaveRecipientControls(
enabled: _saveRecipient.enabled,
isDesktop: true,
onChanged: (value) =>
setState(() => _saveRecipient.enabled = value),
controller: saveRecipientNameController,
focusNode: _saveRecipientFocusNode,
),
if (canSaveRecipient) const SizedBox(height: 12),
],
),
),
Expand Down
120 changes: 120 additions & 0 deletions lib/pages/send_view/save_recipient.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import 'package:uuid/uuid.dart';

import '../../models/isar/models/contact_entry.dart';
import '../../wallets/models/tx_data.dart';

enum SaveRecipientResult { saved, alreadySaved, failed }

typedef SaveRecipientOutcome = ({
SaveRecipientResult result,
Object? error,
StackTrace? stackTrace,
});

/// Confirm screen state behind the optional "save recipient to contacts" step.
///
/// The address book records who was paid, so saving is opt-in: [enabled] starts
/// off and [addressToSave] stays null until the user turns it on.
class SaveRecipientOption {
SaveRecipientOption({required this.address});

/// The address this send may offer to save, or null if it may not offer one.
final String? address;

bool enabled = false;

bool get isOffered => address != null;

/// The address to persist once the send succeeds, or null if none should be.
String? get addressToSave => enabled ? address : null;
}

/// The address a send may offer to save, or null when it may not offer one.
///
/// A savable send has exactly one distinct non change recipient: anything else
/// has no single address to name, or is not a payment to someone else at all.
/// The excluded flows all pay a single use address — a PayNym payment or
/// notification address, an exchange deposit address, the destination of a
/// transaction being fee bumped (already offered when it was first sent), or a
/// Salvium stake, whose "recipient" is the sending wallet itself. Saving those
/// invites address reuse later.
String? savableRecipientAddress({
required TxData txData,
required bool isTradeTransaction,
required bool isPaynymTransaction,
required bool isPaynymNotificationTransaction,
required bool isRbfTransaction,
}) {
if (isTradeTransaction ||
isPaynymTransaction ||
isPaynymNotificationTransaction ||
isRbfTransaction ||
txData.salviumStakeTx) {
return null;
}

final addresses = <String>{
...?txData.recipients
?.where((recipient) => !recipient.isChange)
.map((recipient) => recipient.address.trim())
.where((address) => address.isNotEmpty),
...?txData.sparkRecipients
?.where((recipient) => !recipient.isChange)
.map((recipient) => recipient.address.trim())
.where((address) => address.isNotEmpty),
};

return addresses.length == 1 ? addresses.single : null;
}

/// Adds [address] to the address book unless it is already there.
///
/// Runs after the transaction is on its way, so it reports failures instead of
/// throwing them at a caller that can no longer undo the send.
Future<SaveRecipientOutcome> saveRecipient({
required String address,
required String coinIdentifier,
required String name,
required List<ContactEntry> existingContacts,
required Future<bool> Function(ContactEntry contact) addContact,
}) async {
try {
final alreadySaved = existingContacts.any(
(contact) => contact.addresses.any(
(entry) => entry.address == address && entry.coinName == coinIdentifier,
),
);
if (alreadySaved) {
return (
result: SaveRecipientResult.alreadySaved,
error: null,
stackTrace: null,
);
}

final entry = ContactAddressEntry()
..coinName = coinIdentifier
..address = address
..label = 'Sent to'
..other = null;
final contact = ContactEntry(
name: name.trim().isEmpty ? 'Saved recipient' : name.trim(),
addresses: [entry],
isFavorite: false,
customId: const Uuid().v1(),
);

final saved = await addContact(contact);
return (
result: saved ? SaveRecipientResult.saved : SaveRecipientResult.failed,
error: null,
stackTrace: null,
);
} catch (error, stackTrace) {
return (
result: SaveRecipientResult.failed,
error: error,
stackTrace: stackTrace,
);
}
}
Loading
Loading