Build exchange workspace and shared transaction services (#28)
This commit is contained in:
1 parent
52b7135eda
commit
c496da8a55
48 files changed
+4757
-120
No files matched your search
@@ -0,0 +1,25 @@
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
import '../models/wallet.dart';
|
||||
import 'wallet.dart';
|
||||
|
||||
/// Refetch the backend projection when an operation changes the wallet.
|
||||
class WorkspaceLimitsProvider extends LimitsProvider {
|
||||
WorkspaceLimitsProvider(super.service, this.wallet) {
|
||||
_snapshot = wallet.data;
|
||||
wallet.addListener(_changed);
|
||||
}
|
||||
final WalletProvider wallet;
|
||||
WalletSnapshot? _snapshot;
|
||||
void _changed() {
|
||||
if (identical(_snapshot, wallet.data)) return;
|
||||
_snapshot = wallet.data;
|
||||
refresh();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
wallet.removeListener(_changed);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:sxchnge_analytics/sxchnge_analytics.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';
|
||||
final op = recorder.begin(
|
||||
journey,
|
||||
JourneyStep.transferDraft,
|
||||
simulated: wallet.service.isSimulated,
|
||||
);
|
||||
recorder.finish(op, simulated: wallet.service.isSimulated);
|
||||
}
|
||||
final WalletProvider wallet;
|
||||
final TransferKind kind;
|
||||
final JourneyRecorder recorder;
|
||||
String source;
|
||||
String? destination;
|
||||
String amount = '', recipient = '';
|
||||
Quote? quote;
|
||||
WalletOperation? result;
|
||||
WalletError? error;
|
||||
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 || recipient.isNotEmpty;
|
||||
bool get expired =>
|
||||
quote != null && !DateTime.now().isBefore(quote!.expiresAt);
|
||||
void change({
|
||||
String? source,
|
||||
String? destination,
|
||||
String? amount,
|
||||
String? recipient,
|
||||
}) {
|
||||
if (busy || uncertain) return;
|
||||
this.source = source ?? this.source;
|
||||
this.destination = destination ?? this.destination;
|
||||
this.amount = amount ?? this.amount;
|
||||
this.recipient = recipient ?? this.recipient;
|
||||
quote = null;
|
||||
noSubmission = false;
|
||||
error = null;
|
||||
_key = null;
|
||||
_revision++;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void edit() {
|
||||
if (!busy && !uncertain) {
|
||||
quote = null;
|
||||
error = 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;
|
||||
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(),
|
||||
),
|
||||
);
|
||||
if (_disposed || revision != _revision) return;
|
||||
quote = q;
|
||||
_key = null;
|
||||
recorder.finish(op, simulated: wallet.service.isSimulated);
|
||||
} catch (e) {
|
||||
if (_disposed) return;
|
||||
error = _code(e);
|
||||
recorder.finish(op, failure: _failure(error!));
|
||||
}
|
||||
if (_disposed) return;
|
||||
busy = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> submit() async {
|
||||
if (busy || quote == null || uncertain) return;
|
||||
if (expired) {
|
||||
error = WalletError.expiredQuote;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
noSubmission = false;
|
||||
error = null;
|
||||
notifyListeners();
|
||||
_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!);
|
||||
if (_disposed) return;
|
||||
_accepted(r, op);
|
||||
} catch (e) {
|
||||
if (_disposed) return;
|
||||
error = _code(e);
|
||||
// All transport/unexpected failures are ambiguous after execution started.
|
||||
uncertain = [
|
||||
WalletError.unavailable,
|
||||
WalletError.uncertain,
|
||||
].contains(error);
|
||||
recorder.finish(op, failure: _failure(error!));
|
||||
}
|
||||
if (_disposed) return;
|
||||
busy = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _accepted(WalletOperation value, JourneyOperation op) {
|
||||
result = value;
|
||||
uncertain = false;
|
||||
error = null;
|
||||
recipient = '';
|
||||
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;
|
||||
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;
|
||||
recipient = '';
|
||||
if (!_handedOff) recorder.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
import '../models/wallet.dart';
|
||||
import '../services/contracts.dart';
|
||||
|
||||
class WalletProvider extends ChangeNotifier {
|
||||
WalletProvider(this.service, this.analytics);
|
||||
final WalletService service;
|
||||
final AnalyticsService analytics;
|
||||
WalletSnapshot? data;
|
||||
bool loading = false,
|
||||
failed = false,
|
||||
profileLoading = false,
|
||||
profileFailed = false,
|
||||
saving = false,
|
||||
saveFailed = false;
|
||||
String? preferredLocale;
|
||||
bool _disposed = false;
|
||||
int _revision = 0;
|
||||
Timer? _poll;
|
||||
final _settlements = <String, (JourneyRecorder, JourneyOperation)>{};
|
||||
CurrencyAccount? account(String? id) {
|
||||
for (final a in data?.accounts ?? <CurrencyAccount>[]) {
|
||||
if (a.id == id) return a;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
WalletOperation? operation(String id) {
|
||||
for (final o in data?.operations ?? <WalletOperation>[]) {
|
||||
if (o.id == id) return o;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool get hasPending =>
|
||||
data?.operations.any((o) => o.status == OperationStatus.pending) ?? false;
|
||||
Future<void> refresh() async {
|
||||
final rev = ++_revision;
|
||||
loading = true;
|
||||
failed = false;
|
||||
notifyListeners();
|
||||
try {
|
||||
final result = await service.fetch();
|
||||
if (_disposed || rev != _revision) return;
|
||||
data = result;
|
||||
for (final o in result.operations) {
|
||||
if (o.status == OperationStatus.pending) continue;
|
||||
final pair = _settlements.remove(o.id);
|
||||
if (pair != null) {
|
||||
pair.$1.finish(
|
||||
pair.$2,
|
||||
simulated: service.isSimulated,
|
||||
failure: o.status == OperationStatus.failed
|
||||
? JourneyFailure.declined
|
||||
: null,
|
||||
terminal: true,
|
||||
);
|
||||
pair.$1.dispose();
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
if (_disposed || rev != _revision) return;
|
||||
failed = true;
|
||||
}
|
||||
if (_disposed || rev != _revision) return;
|
||||
loading = false;
|
||||
_poll?.cancel();
|
||||
if (hasPending) {
|
||||
_poll = Timer(const Duration(seconds: 3), () {
|
||||
if (!_disposed) refresh();
|
||||
});
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void track(
|
||||
WalletOperation operation,
|
||||
JourneyRecorder recorder,
|
||||
JourneyKind kind,
|
||||
) {
|
||||
final step = recorder.begin(
|
||||
kind,
|
||||
JourneyStep.settlement,
|
||||
simulated: service.isSimulated,
|
||||
);
|
||||
_settlements[operation.id] = (recorder, step);
|
||||
final snapshot = data;
|
||||
if (snapshot != null) {
|
||||
data = WalletSnapshot(
|
||||
accounts: snapshot.accounts,
|
||||
operations: [
|
||||
operation,
|
||||
...snapshot.operations.where((o) => o.id != operation.id),
|
||||
],
|
||||
);
|
||||
}
|
||||
unawaited(refresh());
|
||||
}
|
||||
|
||||
Future<void> loadProfile() async {
|
||||
if (profileLoading) return;
|
||||
profileLoading = true;
|
||||
profileFailed = false;
|
||||
notifyListeners();
|
||||
try {
|
||||
final result = await service.profile();
|
||||
if (_disposed) return;
|
||||
preferredLocale = result.locale;
|
||||
} catch (_) {
|
||||
if (_disposed) return;
|
||||
profileFailed = true;
|
||||
}
|
||||
profileLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<bool> saveLocale(String locale) async {
|
||||
if (saving) return false;
|
||||
saving = true;
|
||||
saveFailed = false;
|
||||
notifyListeners();
|
||||
try {
|
||||
final result = await service.saveLocale(locale);
|
||||
if (_disposed) return false;
|
||||
preferredLocale = result.locale;
|
||||
} catch (_) {
|
||||
if (_disposed) return false;
|
||||
saveFailed = true;
|
||||
}
|
||||
saving = false;
|
||||
notifyListeners();
|
||||
return !saveFailed;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_poll?.cancel();
|
||||
for (final p in _settlements.values) {
|
||||
p.$1.dispose(abandonActive: false);
|
||||
}
|
||||
_settlements.clear();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user