Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:quantus_sdk/quantus_sdk.dart';
import 'package:resonance_network_wallet/features/styles/app_colors_theme.dart';
import 'package:resonance_network_wallet/features/styles/app_text_theme.dart';
import 'package:resonance_network_wallet/providers/account_providers.dart';
import 'package:resonance_network_wallet/providers/l10n_provider.dart';
import 'package:resonance_network_wallet/providers/wallet_providers.dart';
import 'package:resonance_network_wallet/routes.dart';
Expand All @@ -15,8 +16,8 @@ import 'package:resonance_network_wallet/shared/extensions/toaster_extensions.da
import 'package:resonance_network_wallet/shared/utils/print.dart';
import 'package:resonance_network_wallet/v2/components/quantus_button.dart';
import 'package:resonance_network_wallet/v2/screens/send/input_amount_screen.dart';
import 'package:resonance_network_wallet/v2/screens/send/keystone_sign_cache.dart';
import 'package:resonance_network_wallet/v2/screens/send/regular_send_strategy.dart';
import 'package:resonance_network_wallet/v2/screens/send/send_providers.dart';

class SharedAddressActionSheet extends StatefulWidget {
final String address;
Expand Down Expand Up @@ -71,12 +72,18 @@ class _SharedAddressActionSheetState extends State<SharedAddressActionSheet> {
context.showErrorToaster(message: container.read(l10nProvider).invalidAddress);
return;
}
container.read(keystoneSignCacheProvider.notifier).startNewSendSession();
final active = container.read(activeAccountProvider).value;
if (active is! RegularAccount) {
quantusPrint('shared address send: active account cannot send regular transfers');
context.showWarningToaster(message: container.read(l10nProvider).sendRegularAccountRequired);
return;
}
Navigator.of(context).pop();
Navigator.push(
startSendFlow(
context,
MaterialPageRoute(
builder: (_) => InputAmountScreen(strategy: const RegularSendStrategy(), recipientAddress: widget.address),
screen: InputAmountScreen(
strategy: RegularSendStrategy(account: active.account),
recipientAddress: widget.address,
),
);
}
Expand Down
4 changes: 4 additions & 0 deletions mobile-app/lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -1404,6 +1404,10 @@
"@sendReviewSubmitFailed": {
"description": "Error when transaction submission fails"
},
"sendRegularAccountRequired": "Switch to a regular account to send",
"@sendRegularAccountRequired": {
"description": "Toast when a send is requested while the active account cannot make regular transfers"
},

"sendTxSubmittedHeadlinePaid": "{amount} {symbol} paid",
"@sendTxSubmittedHeadlinePaid": {
Expand Down
1 change: 1 addition & 0 deletions mobile-app/lib/l10n/app_id.arb
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@
"sendReviewAuthReason": "Autentikasi untuk mengonfirmasi transaksi",
"sendReviewAuthRequired": "Autentikasi diperlukan untuk mengirim",
"sendReviewSubmitFailed": "Gagal mengirim transaksi",
"sendRegularAccountRequired": "Beralih ke akun reguler untuk mengirim",

"sendTxSubmittedHeadlinePaid": "{amount} {symbol} dibayar",
"sendTxSubmittedHeadlineSent": "{amount} {symbol} terkirim",
Expand Down
6 changes: 6 additions & 0 deletions mobile-app/lib/l10n/app_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1898,6 +1898,12 @@ abstract class AppLocalizations {
/// **'Failed submitting transaction'**
String get sendReviewSubmitFailed;

/// Toast when a send is requested while the active account cannot make regular transfers
///
/// In en, this message translates to:
/// **'Switch to a regular account to send'**
String get sendRegularAccountRequired;

/// Success headline when payment completed
///
/// In en, this message translates to:
Expand Down
3 changes: 3 additions & 0 deletions mobile-app/lib/l10n/app_localizations_en.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1010,6 +1010,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get sendReviewSubmitFailed => 'Failed submitting transaction';

@override
String get sendRegularAccountRequired => 'Switch to a regular account to send';

@override
String sendTxSubmittedHeadlinePaid(String amount, String symbol) {
return '$amount $symbol paid';
Expand Down
3 changes: 3 additions & 0 deletions mobile-app/lib/l10n/app_localizations_id.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1007,6 +1007,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get sendReviewSubmitFailed => 'Gagal mengirim transaksi';

@override
String get sendRegularAccountRequired => 'Beralih ke akun reguler untuk mengirim';

@override
String sendTxSubmittedHeadlinePaid(String amount, String symbol) {
return '$amount $symbol dibayar';
Expand Down
34 changes: 18 additions & 16 deletions mobile-app/lib/providers/wallet_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -138,23 +138,25 @@ final effectiveBalanceProviderFamily = Provider.family<AsyncValue<BigInt>, Strin
final pendingMultisigCancellations = ref.watch(pendingMultisigCancellationsProvider);
final pendingMultisigCreations = ref.watch(pendingMultisigCreationsProvider);

return balanceAsync.when(
data: (blockchainBalance) {
final pendingOutgoing = _calculatePendingOutgoing(
pendingTransactions,
pendingMultisigProposals,
pendingMultisigExecutions,
pendingMultisigCancellations,
pendingMultisigCreations,
accountId,
);
final effectiveBalance = blockchainBalance - pendingOutgoing;
final result = effectiveBalance >= BigInt.zero ? effectiveBalance : BigInt.zero;
return AsyncValue.data(result);
},
loading: () => const AsyncValue.loading(),
error: (err, stack) => AsyncValue.error(err, stack),
// AsyncError/AsyncLoading preserve the previously fetched balance; keep
// using it so a transient refresh error can't zero out spendable-balance
// checks mid-send. Errors before any successful fetch still propagate.
final blockchainBalance = balanceAsync.value;
if (blockchainBalance == null) {
return balanceAsync.isLoading
? const AsyncValue.loading()
: AsyncValue.error(balanceAsync.error!, balanceAsync.stackTrace!);
}
final pendingOutgoing = _calculatePendingOutgoing(
pendingTransactions,
pendingMultisigProposals,
pendingMultisigExecutions,
pendingMultisigCancellations,
pendingMultisigCreations,
accountId,
);
final effectiveBalance = blockchainBalance - pendingOutgoing;
return AsyncValue.data(effectiveBalance >= BigInt.zero ? effectiveBalance : BigInt.zero);
});

// Raw blockchain balance (without pending transaction adjustments)
Expand Down
85 changes: 52 additions & 33 deletions mobile-app/lib/v2/screens/home/home_screen.dart
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/legacy.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:quantus_sdk/quantus_sdk.dart';
import 'package:resonance_network_wallet/features/components/dotted_border.dart';
import 'package:resonance_network_wallet/features/components/skeleton.dart';
import 'package:resonance_network_wallet/features/components/shared_address_action_sheet.dart';
import 'package:resonance_network_wallet/routes.dart';
import 'package:resonance_network_wallet/services/global_history_polling_service.dart';
import 'package:resonance_network_wallet/services/telemetry_service.dart';
import 'package:resonance_network_wallet/shared/constants/e2e_keys.dart';
import 'package:resonance_network_wallet/shared/extensions/current_route_extensions.dart';
import 'package:resonance_network_wallet/shared/extensions/toaster_extensions.dart';
import 'package:resonance_network_wallet/shared/utils/print.dart';
import 'package:resonance_network_wallet/shared/utils/url_utils.dart';
import 'package:resonance_network_wallet/v2/components/amount_display_with_conversion.dart';
Expand All @@ -26,7 +26,6 @@ import 'package:resonance_network_wallet/v2/screens/multisig/multisig_activity_s
import 'package:resonance_network_wallet/v2/screens/multisig/multisig_proposal_detail_sheet.dart';
import 'package:resonance_network_wallet/v2/screens/send/encrypted_send_strategy.dart';
import 'package:resonance_network_wallet/v2/screens/send/input_amount_screen.dart';
import 'package:resonance_network_wallet/v2/screens/send/keystone_sign_cache.dart';
import 'package:resonance_network_wallet/v2/screens/send/multisig_propose_strategy.dart';
import 'package:resonance_network_wallet/v2/screens/send/regular_send_strategy.dart';
import 'package:resonance_network_wallet/v2/screens/send/select_recipient_screen.dart';
Expand All @@ -38,11 +37,13 @@ import 'package:resonance_network_wallet/l10n/app_localizations.dart';
import 'package:resonance_network_wallet/providers/account_providers.dart';
import 'package:resonance_network_wallet/providers/l10n_provider.dart';
import 'package:resonance_network_wallet/providers/active_account_transactions_provider.dart';
import 'package:resonance_network_wallet/providers/local_auth_provider.dart';
import 'package:resonance_network_wallet/providers/multisig_providers.dart';
import 'package:resonance_network_wallet/providers/route_intent_providers.dart';
import 'package:resonance_network_wallet/providers/currency_display_provider.dart';
import 'package:resonance_network_wallet/providers/wallet_providers.dart';
import 'package:resonance_network_wallet/v2/components/scaffold_base.dart';
import 'package:resonance_network_wallet/v2/screens/send/send_providers.dart';
import 'package:resonance_network_wallet/v2/theme/app_colors.dart';
import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart';
import 'package:resonance_network_wallet/v2/components/global_toast_listener.dart';
Expand All @@ -67,18 +68,40 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
ref.listenManual<ProposalIntent?>(proposalIntentProvider, _onProposalIntent);
ref.listenManual<AsyncValue<DisplayAccount?>>(activeAccountProvider, (_, async) {
if (async.value == null) return;
_onTransactionIntent(null, ref.read(transactionIntentProvider));
// Accounts may still be loading when an intent arrives on a cold start;
// retry once they are available.
_drainPendingIntents();
});
// Multisig accounts may still be loading when a proposal intent arrives on a
// cold start; retry once they are available.
ref.listenManual<AsyncValue<List<MultisigAccount>>>(multisigAccountsProvider, (_, async) {
if (async.value == null) return;
_onProposalIntent(null, ref.read(proposalIntentProvider));
});
// Intents are only consumed while unlocked: anything arriving beneath the
// lock overlay stays queued in its provider and is drained after unlock.
ref.listenManual<LocalAuthState>(localAuthProvider, (prev, next) {
if (_isAuthStateUnlocked(next) && (prev == null || !_isAuthStateUnlocked(prev))) {
_drainPendingIntents();
}
});

Future.microtask(_drainPendingIntents);
}

bool _isAuthStateUnlocked(LocalAuthState auth) => auth.isAuthenticated && !auth.isVisuallyLocked;

bool get _isUnlocked => _isAuthStateUnlocked(ref.read(localAuthProvider));

/// A send in flight must never be interrupted: intents that would start
/// another flow or switch the active account are dropped, not queued.
bool _consumeIfSendInFlight<T>(StateProvider<T?> provider, String label) {
if (!ref.read(sendFlowActiveProvider)) return false;
ref.read(provider.notifier).state = null;
quantusPrint('$label intent ignored: send flow active');
return true;
}

void _drainPendingIntents() {
if (!mounted) return;
_onTransactionIntent(null, ref.read(transactionIntentProvider));
Expand All @@ -88,7 +111,7 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
}

void _onTransactionIntent(TransactionEvent? _, TransactionEvent? transaction) {
if (transaction == null || !mounted) return;
if (transaction == null || !mounted || !_isUnlocked) return;
final active = ref.read(activeAccountProvider).value;
if (active == null) return;
ref.read(transactionIntentProvider.notifier).state = null;
Expand All @@ -97,29 +120,32 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
}

void _onPaymentIntent(PaymentIntent? _, PaymentIntent? payment) {
if (payment == null || !mounted) return;
if (payment == null || !mounted || !_isUnlocked) return;
if (_consumeIfSendInFlight(paymentIntentProvider, 'payment')) return;
final active = ref.read(activeAccountProvider).value;
// Still loading — the activeAccountProvider listener will retry.
if (active == null) return;
ref.read(paymentIntentProvider.notifier).state = null;
ref.read(keystoneSignCacheProvider.notifier).startNewSendSession();
if (active is! RegularAccount) {
quantusPrint('payment intent: active account cannot send regular transfers');
context.showWarningToaster(message: ref.read(l10nProvider).sendRegularAccountRequired);
return;
}

final pageRoute = MaterialPageRoute(
builder: (_) => InputAmountScreen(
strategy: const RegularSendStrategy(),
startSendFlow(
context,
screen: InputAmountScreen(
strategy: RegularSendStrategy(account: active.account),
recipientAddress: payment.to,
initialAmount: payment.amount,
isPayMode: true,
),
settings: inputAmountScreenRouteSettings,
);

if (context.peekTopRouteName == inputAmountScreenRouteSettings.name) {
Navigator.pushReplacement(context, pageRoute);
} else {
Navigator.push(context, pageRoute);
}
}

void _onSharedIntent(String? _, String? shared) {
if (shared == null || !mounted) return;
if (shared == null || !mounted || !_isUnlocked) return;
if (_consumeIfSendInFlight(sharedAccountIntentProvider, 'shared account')) return;
ref.read(sharedAccountIntentProvider.notifier).state = null;

showSharedAddressActionSheet(context, shared);
Expand All @@ -129,7 +155,8 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
/// the active account, then opens the detail sheet immediately. The sheet
/// shows a loader while it resolves the proposal by id.
Future<void> _onProposalIntent(ProposalIntent? _, ProposalIntent? intent) async {
if (intent == null || !mounted) return;
if (intent == null || !mounted || !_isUnlocked) return;
if (_consumeIfSendInFlight(proposalIntentProvider, 'proposal')) return;

final multisigAccounts = ref.read(multisigAccountsProvider).value;
// Still loading — the multisigAccountsProvider listener will retry.
Expand Down Expand Up @@ -341,7 +368,7 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
const enableSwap = true; // ref.watch(remoteConfigProvider).enableSwap; Override enable swap config for now
final SendStrategy sendStrategy = isEncrypted
? EncryptedSendStrategy(account: account)
: const RegularSendStrategy();
: RegularSendStrategy(account: account);

final receiveCard = _actionCard(
iconAsset: 'assets/v2/action_receive.svg',
Expand All @@ -353,10 +380,7 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
key: const Key(E2EKeys.homeSendButton),
iconAsset: 'assets/v2/action_send.svg',
label: l10n.homeSend,
onTap: () {
ref.read(keystoneSignCacheProvider.notifier).startNewSendSession();
Navigator.push(context, MaterialPageRoute(builder: (_) => SelectRecipientScreen(strategy: sendStrategy)));
},
onTap: () => startSendFlow(context, screen: SelectRecipientScreen(strategy: sendStrategy)),
);

final swapCard = _actionCard(
Expand Down Expand Up @@ -394,15 +418,10 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
_actionCard(
iconAsset: 'assets/v2/action_send.svg',
label: l10n.multisigProposeTitle,
onTap: () {
ref.read(keystoneSignCacheProvider.notifier).startNewSendSession();
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => SelectRecipientScreen(strategy: MultisigProposeStrategy(msig: msig)),
),
);
},
onTap: () => startSendFlow(
context,
screen: SelectRecipientScreen(strategy: MultisigProposeStrategy(msig: msig)),
),
),
],
);
Expand Down
23 changes: 13 additions & 10 deletions mobile-app/lib/v2/screens/send/regular_send_strategy.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart';
import 'package:quantus_sdk/quantus_sdk.dart';
import 'package:resonance_network_wallet/l10n/app_localizations.dart';
import 'package:resonance_network_wallet/providers/account_providers.dart';
import 'package:resonance_network_wallet/providers/l10n_provider.dart';
import 'package:resonance_network_wallet/providers/wallet_providers.dart';
import 'package:resonance_network_wallet/services/local_auth_service.dart';
Expand All @@ -21,13 +20,19 @@ import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart';

/// Standard single-signer transfer from the active account. Signs locally, or
/// hands off to the Keystone QR flow for hardware accounts.
///
/// The source [account] is captured when the flow starts and used for the whole
/// flow (fee estimation, balance validation, submission), so a mid-flow account
/// switch can never change the account being signed from.
class RegularSendStrategy extends SendStrategy {
const RegularSendStrategy();
final Account account;

const RegularSendStrategy({required this.account});

static final BigInt _estimateFeeAmount = BigInt.from(1000) * NumberFormattingService.scaleFactorBigInt;

@override
String? sourceAccountId(WidgetRef ref) => ref.read(activeAccountProvider).value?.account.accountId;
String? sourceAccountId(WidgetRef ref) => account.accountId;

@override
SendStrings strings(AppLocalizations l10n) => SendStrings(
Expand All @@ -42,7 +47,8 @@ class RegularSendStrategy extends SendStrategy {
);

@override
ProviderListenable<AsyncValue<BigInt>> get spendableBalanceProvider => effectiveMaxBalanceProvider;
ProviderListenable<AsyncValue<BigInt>> get spendableBalanceProvider =>
effectiveMaxBalanceProviderFamily(account.accountId);

@override
bool extraBalancesLoading(WidgetRef ref) => false;
Expand All @@ -52,11 +58,6 @@ class RegularSendStrategy extends SendStrategy {

@override
Future<SendFee> estimateFee(WidgetRef ref, {required String recipient, required BigInt amount}) async {
final displayAccount = ref.read(activeAccountProvider).value;
if (displayAccount is! RegularAccount) {
throw StateError('Regular send requires an active regular account');
}
final account = displayAccount.account;
final useReal = amount > BigInt.zero && ref.read(substrateServiceProvider).isValidSS58Address(recipient);
final feeAmount = useReal ? amount : _estimateFeeAmount;
final toAddress = useReal ? recipient : account.accountId;
Expand Down Expand Up @@ -110,7 +111,9 @@ class RegularSendStrategy extends SendStrategy {
final fmt = ref.read(numberFormattingServiceProvider);
final regularFee = fee as RegularFee;
final recipient = recipientAddress.trim();
final account = (await SettingsService().getActiveRegularAccount())!;
// Sign from the account captured when the flow started, not whichever
// account happens to be active at submit time.
final account = this.account;
final terminal = buildSentTerminalContent(
l10n,
fmt,
Expand Down
Loading
Loading