359 lines
9.8 KiB
Dart
359 lines
9.8 KiB
Dart
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<void> 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<void> 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<FactorChallenge?> 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<void> 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<void> 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();
|
|
}
|
|
}
|