import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:sxchnge_analytics/sxchnge_analytics.dart'; import 'package:sxchnge_auth/sxchnge_auth.dart'; import '../destinations/models.dart'; import '../models/wallet.dart'; import 'wallet.dart'; class TransferProvider extends ChangeNotifier { TransferProvider(this.wallet, this.kind, {String? source}) : source = source ?? wallet.data?.accounts.firstOrNull?.id ?? 'eur', recorder = JourneyRecorder(wallet.analytics) { destination = wallet.data?.accounts .where((a) => a.id != this.source) .firstOrNull ?.id ?? 'usd'; wallet.addListener(_walletChanged); _syncAccounts(); final op = recorder.begin( journey, JourneyStep.transferDraft, simulated: wallet.service.isSimulated, ); recorder.finish(op, simulated: wallet.service.isSimulated); } void _syncAccounts() { final accounts = wallet.data?.accounts; if (accounts == null || accounts.isEmpty || busy || uncertain || quote != null) return; if (!accounts.any((a) => a.id == source)) source = accounts.first.id; if (!accounts.any((a) => a.id == destination) || destination == source) { destination = accounts.where((a) => a.id != source).firstOrNull?.id; } } void _walletChanged() { if (_disposed) return; final previous = source; _syncAccounts(); if (source != previous) { payoutDestination = null; payoutLabel = ''; recipient = ''; notifyListeners(); } } final WalletProvider wallet; final TransferKind kind; final JourneyRecorder recorder; String source; String? destination; String amount = '', recipient = ''; DestinationInput? payoutDestination; String payoutLabel = ''; Quote? quote; WalletOperation? result; WalletError? error; FactorError? factorError; bool noSubmission = false; bool busy = false, uncertain = false, _disposed = false, _handedOff = false; int _revision = 0; String? _key; JourneyKind get journey => switch (kind) { TransferKind.topUp => JourneyKind.topUp, TransferKind.exchange => JourneyKind.exchange, TransferKind.payout => JourneyKind.payout, }; bool get hasDraft => amount.isNotEmpty || payoutDestination != null || recipient.isNotEmpty; bool get expired => quote != null && !DateTime.now().isBefore(quote!.expiresAt); void change({ String? source, String? destination, String? amount, String? recipient, DestinationInput? payoutDestination, String? payoutLabel, }) { if (busy || uncertain) return; if (source != null && source != this.source) { this.payoutDestination = null; this.payoutLabel = ''; recipient = ''; } this.payoutDestination = payoutDestination ?? this.payoutDestination; this.payoutLabel = payoutLabel ?? this.payoutLabel; this.source = source ?? this.source; this.destination = destination ?? this.destination; this.amount = amount ?? this.amount; this.recipient = recipient ?? this.recipient; if (source != null) _syncAccounts(); quote = null; noSubmission = false; error = null; factorError = null; _key = null; _revision++; notifyListeners(); } Future selectPayout(DestinationInput input) async { if (busy || uncertain || wallet.destinations == null) return; busy = true; error = null; notifyListeners(); try { final service = wallet.destinations!; final assessment = await service.assess( input, PayoutContext(source, amount: wallet.account(source)?.parse(amount)), ); if (!assessment.eligible) throw const DestinationException(DestinationIssue.unsupported); final details = input is OneTimeDestination ? input.details : (await service.get((input as SavedReference).id)).details; if (_disposed) return; busy = false; change(payoutDestination: input, payoutLabel: details.display); } catch (_) { if (_disposed) return; payoutDestination = null; payoutLabel = ''; error = WalletError.destinationChanged; } if (_disposed) return; busy = false; notifyListeners(); } void edit() { if (!busy && !uncertain) { quote = null; error = null; factorError = null; _key = null; notifyListeners(); } } Future review() async { if (busy || uncertain) return; final money = wallet.account(source)?.parse(amount); if (money == null || money <= 0) { error = WalletError.invalidAmount; notifyListeners(); return; } busy = true; noSubmission = false; error = null; factorError = null; notifyListeners(); final revision = ++_revision; final op = recorder.begin( journey, JourneyStep.transferQuote, simulated: wallet.service.isSimulated, ); try { final q = await wallet.service.quote( TransferRequest( kind: kind, source: source, amount: money, destination: kind == TransferKind.exchange ? destination : null, recipient: recipient.trim(), payoutDestination: payoutDestination, ), ); if (_disposed || revision != _revision) return; quote = q; _key = null; recorder.finish(op, simulated: wallet.service.isSimulated); } catch (e) { if (_disposed) return; error = _code(e); if (error == WalletError.destinationChanged) { payoutDestination = null; payoutLabel = ''; } recorder.finish(op, failure: _failure(error!)); } if (_disposed) return; busy = false; notifyListeners(); } Future challenge() async { if (busy || uncertain || quote == null) return null; if (expired) { error = WalletError.expiredQuote; notifyListeners(); return null; } busy = true; error = null; factorError = null; notifyListeners(); FactorChallenge? result; final op = recorder.begin( journey, JourneyStep.factorChallenge, simulated: wallet.service.isSimulated, ); try { result = await wallet.factors?.begin( FactorPurpose.values.byName(kind.name), quote!.id, ); } catch (e) { if (!_disposed) { factorError = e is FactorException ? e.code : FactorError.unavailable; } } if (_disposed) return null; busy = false; notifyListeners(); recorder.finish( op, simulated: wallet.service.isSimulated, failure: factorError == null ? null : JourneyFailure.unavailable, ); return result; } Future submit({FactorGrant? approval}) async { if (busy || quote == null || uncertain) return; if (expired) { error = WalletError.expiredQuote; notifyListeners(); return; } busy = true; noSubmission = false; error = null; factorError = null; notifyListeners(); _key ??= approval?.token; _key ??= List.generate( 16, (_) => Random.secure().nextInt(256).toRadixString(16).padLeft(2, '0'), ).join(); final op = recorder.begin( journey, JourneyStep.transferSubmission, simulated: wallet.service.isSimulated, ); try { final r = await wallet.service.submit( quote!.id, _key!, approval: approval?.token, ); if (_disposed) return; _accepted(r, op); } catch (e) { if (_disposed) return; if (e is FactorException) { error = null; factorError = e.code; uncertain = false; } else { error = _code(e); if (error == WalletError.destinationChanged) { quote = null; payoutDestination = null; payoutLabel = ''; } } // All transport/unexpected failures are ambiguous after execution started. uncertain = [ WalletError.unavailable, WalletError.uncertain, ].contains(error); recorder.finish( op, failure: error == null ? JourneyFailure.invalid : _failure(error!), ); } if (_disposed) return; busy = false; notifyListeners(); } void _accepted(WalletOperation value, JourneyOperation op) { result = value; uncertain = false; error = null; factorError = null; recipient = ''; payoutDestination = null; recorder.finish(op, simulated: wallet.service.isSimulated); wallet.track(value, recorder, journey); _handedOff = true; } Future reconcile() async { if (busy || !uncertain || _key == null) return; busy = true; noSubmission = false; error = null; factorError = null; notifyListeners(); final op = recorder.begin( journey, JourneyStep.transferSubmission, simulated: wallet.service.isSimulated, ); try { final r = await wallet.service.findSubmission(_key!); if (_disposed) return; if (r != null) { _accepted(r, op); } else { uncertain = false; noSubmission = true; recorder.finish(op, simulated: wallet.service.isSimulated); } } catch (e) { if (_disposed) return; error = _code(e); recorder.finish(op, failure: JourneyFailure.unavailable); } if (_disposed) return; busy = false; notifyListeners(); } WalletError _code(Object e) => e is WalletException ? e.code : WalletError.unavailable; JourneyFailure _failure(WalletError e) => switch (e) { WalletError.insufficientFunds => JourneyFailure.insufficientFunds, WalletError.limitExceeded => JourneyFailure.limitExceeded, WalletError.expiredQuote => JourneyFailure.expired, _ => JourneyFailure.unavailable, }; @override void dispose() { _disposed = true; wallet.removeListener(_walletChanged); recipient = ''; if (!_handedOff) recorder.dispose(); super.dispose(); } }