Add saved payout destination UX and service contracts (#54)

This commit is contained in:
Stephan D committed 2026-10-16 12:01:33 +02:00
1 parent a5689876ff
commit 24af794112
49 files changed
+4687 -62

No files matched your search

@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
const testDestination = CryptoDestination(
assetId: 'demo:ethereum:native',
networkId: 'eip155:1',
address: '0x1234567890123456789012345678901234567890',
assetLabel: 'ETH',
networkLabel: 'Ethereum',
);
Future<void> seedDestination(DummyDestinationService service) async {
await service.create(
testDestination,
'Test wallet',
'test-destination-creation',
);
}
Future<void> selectPayout(WidgetTester t) async {
final button = find.byKey(const ValueKey('choose-payout-destination'));
await t.ensureVisible(button);
await t.tap(button);
await t.pumpAndSettle();
final row = find.text('Test wallet');
await t.ensureVisible(row);
await t.tap(row);
await t.pumpAndSettle();
}
+245
View File
@@ -0,0 +1,245 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import 'package:sxchnge_app/main.dart';
import 'package:sxchnge_app/providers/locale.dart';
import 'destination_helpers.dart';
Future<void> click(WidgetTester t, Finder finder) async {
await t.ensureVisible(finder);
await t.pumpAndSettle();
await t.tap(finder);
await t.pumpAndSettle();
}
Future<DummyWalletService> open(
WidgetTester t, {
String path = '/settings/payout-methods',
String locale = 'en',
DummyDestinationService? destinations,
DummyAnalyticsService? analytics,
}) async {
t.platformDispatcher.defaultRouteNameTestValue = path;
addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue);
final wallet = DummyWalletService(
DummyIdentityService(delay: Duration.zero),
delay: Duration.zero,
destinations: destinations,
initialLocale: locale,
settlement: Duration.zero,
);
await t.pumpWidget(
SxchngeApp(
walletService: wallet,
analyticsService: analytics,
localeProvider: LocaleProvider(initialLocale: Locale(locale)),
),
);
await t.pumpAndSettle();
addTearDown(() => t.pumpWidget(const SizedBox.shrink()));
return wallet;
}
Future<void> cryptoForm(WidgetTester t, {bool payout = false}) async {
await click(
t,
find.text(payout ? 'Use a new destination' : 'Add payout method').first,
);
await click(t, find.byType(DropdownButtonFormField<DestinationType>));
await click(t, find.text('Crypto address').last);
await click(t, find.byKey(const ValueKey('asset-null')));
await click(t, find.text('ETH').last);
await click(t, find.byType(DropdownButtonFormField<String>).last);
await click(t, find.text('Ethereum').last);
await t.enterText(
find.byKey(const ValueKey('destination-address')),
testDestination.address,
);
await t.pump();
}
void main() {
testWidgets('Settings saves without nickname and exposes renamed method', (
t,
) async {
final wallet = await open(t);
await cryptoForm(t);
expect(find.text('Save for future payouts'), findsNothing);
await click(t, find.byKey(const ValueKey('destination-continue')));
expect((await wallet.destinations.list()).length, 1);
expect(find.textContaining('ETH · Ethereum'), findsOneWidget);
await click(t, find.textContaining('ETH · Ethereum'));
await t.enterText(
find.byKey(const ValueKey('destination-nickname')),
'My own wallet',
);
await click(t, find.text('Rename'));
expect((await wallet.destinations.list()).single.nickname, 'My own wallet');
await click(t, find.text('Remove'));
expect(find.textContaining('Submitted payouts'), findsOneWidget);
await click(t, find.widgetWithText(FilledButton, 'Remove'));
expect(await wallet.destinations.list(), isEmpty);
expect(t.takeException(), isNull);
});
testWidgets('new one-time payout keeps saving unchecked and never persists', (
t,
) async {
final wallet = await open(t, path: '/payout');
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
await click(t, find.byKey(const ValueKey('choose-payout-destination')));
await cryptoForm(t, payout: true);
expect(
t.widget<CheckboxListTile>(find.byType(CheckboxListTile)).value,
isFalse,
);
expect(find.byKey(const ValueKey('destination-nickname')), findsNothing);
await click(t, find.byKey(const ValueKey('destination-continue')));
await click(t, find.byKey(const ValueKey('transfer-review')));
expect(find.textContaining(testDestination.address), findsOneWidget);
await click(t, find.byKey(const ValueKey('transfer-confirm')));
expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget);
expect(await wallet.destinations.list(), isEmpty);
});
testWidgets(
'optional saving failure permits explicit one-time continuation',
(t) async {
final service = DummyDestinationService();
final wallet = await open(t, path: '/payout', destinations: service);
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
await click(t, find.byKey(const ValueKey('choose-payout-destination')));
await cryptoForm(t, payout: true);
await click(t, find.text('Save for future payouts'));
service.nextSaveError = DestinationIssue.conflict;
await click(t, find.byKey(const ValueKey('destination-continue')));
expect(find.textContaining('Couldn’t save'), findsOneWidget);
await click(t, find.text('Continue without saving'));
await click(t, find.byKey(const ValueKey('transfer-review')));
await click(t, find.byKey(const ValueKey('transfer-confirm')));
expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget);
expect(await wallet.destinations.list(), isEmpty);
},
);
testWidgets(
'saved destination is never selected automatically and removal preserves amount',
(t) async {
final service = DummyDestinationService();
await seedDestination(service);
final wallet = await open(t, path: '/payout', destinations: service);
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
expect(
t
.widget<FilledButton>(find.byKey(const ValueKey('transfer-review')))
.onPressed,
isNull,
);
await selectPayout(t);
await click(t, find.byKey(const ValueKey('transfer-review')));
final d = (await service.list()).single;
await service.remove(d.id, d.metadataRevision, d.executionRevision);
await click(t, find.byKey(const ValueKey('transfer-confirm')));
expect(find.textContaining('Payout terms changed'), findsOneWidget);
expect(find.text('5'), findsOneWidget);
expect((await t.runAsync(wallet.fetch))!.operations, isEmpty);
},
);
for (final locale in ['en', 'ru']) {
testWidgets('all method forms fit $locale at 320px with large text', (
t,
) async {
t.view.physicalSize = const Size(320, 1100);
t.view.devicePixelRatio = 1;
t.platformDispatcher.textScaleFactorTestValue = 1.6;
addTearDown(t.view.resetPhysicalSize);
addTearDown(t.view.resetDevicePixelRatio);
addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue);
await open(t, locale: locale);
await click(
t,
find
.text(
locale == 'en' ? 'Add payout method' : 'Добавить способ вывода',
)
.first,
);
for (final name
in locale == 'en'
? ['Crypto address', 'Bank card', 'Russian bank account']
: [
'Криптоадрес',
'Банковская карта',
'Российский банковский счёт',
]) {
await click(t, find.byType(DropdownButtonFormField<DestinationType>));
await click(t, find.text(name).last);
expect(t.takeException(), isNull);
}
});
}
testWidgets('semantic save outcomes contain no destination fields', (
t,
) async {
final analytics = DummyAnalyticsService();
await open(t, analytics: analytics);
await cryptoForm(t);
await click(t, find.byKey(const ValueKey('destination-continue')));
await t.pumpAndSettle();
final events = analytics.events
.where((e) => e.kind == JourneyKind.destination)
.toList();
expect(
events.any(
(e) =>
e.step == JourneyStep.destinationSave &&
e.signal == JourneySignal.simulated,
),
isTrue,
);
expect(
events.map((e) => e.properties.toString()).join(),
isNot(contains(testDestination.address)),
);
});
testWidgets(
'binding recovery uses secure capture and preserves the saved card',
(t) async {
final service = DummyDestinationService();
final created = await service.create(
const CardDestination(
capture: 'demo-card-recover',
brand: '',
last4: '',
expiryMonth: 0,
expiryYear: 0,
),
'Own card',
'creation-key-000001',
);
await open(t, destinations: service);
await click(t, find.text('Own card'));
await click(t, find.text('Update authorization'));
expect(find.byKey(const ValueKey('destination-nickname')), findsNothing);
await click(t, find.text('Demo card •••• 4242'));
await click(t, find.byKey(const ValueKey('destination-continue')));
final saved = await service.get(created.destinationId);
expect(saved.executionRevision, 2);
expect(saved.nickname, 'Own card');
expect((await service.list()).length, 1);
expect(
(await service.assess(
saved.input,
const PayoutContext('eur'),
)).eligible,
isTrue,
);
},
);
}
@@ -76,6 +76,14 @@ void main() {
await t.ensureVisible(review);
if (hasMethod) {
expect(find.text(destinationLabel), findsOneWidget);
final choose = find.byKey(
const ValueKey('choose-configured-destination'),
);
expect(t.widget<FilledButton>(review).onPressed, isNull);
await t.ensureVisible(choose);
await t.tap(choose);
await t.pumpAndSettle();
await t.ensureVisible(review);
await t.tap(review);
await t.pumpAndSettle();
expect(wallet.received, 'method-ref');
+15 -4
View File
@@ -6,9 +6,15 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import 'package:sxchnge_app/main.dart';
import 'package:sxchnge_app/providers/locale.dart';
import 'destination_helpers.dart';
Future<void> tapFactor(WidgetTester t, String key) async {
final finder = find.byKey(ValueKey(key));
await t.ensureVisible(finder);
@@ -31,8 +37,16 @@ Future<DummyTwoFactorService> openFactor(
delay: Duration.zero,
method: method,
);
final wallet = DummyWalletService(
DummyIdentityService(delay: Duration.zero),
factors: service,
initialLocale: locale,
delay: Duration.zero,
);
await seedDestination(wallet.destinations);
await t.pumpWidget(
SxchngeApp(
walletService: wallet,
twoFactorService: service,
analyticsService: analytics,
localeProvider: LocaleProvider(initialLocale: Locale(locale)),
@@ -174,10 +188,7 @@ void main() {
await openFactor(t, path: '/$kind', enabled: true);
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10');
if (kind == 'payout') {
await t.enterText(
find.byKey(const ValueKey('transfer-recipient')),
'Sample recipient',
);
await selectPayout(t);
}
await tapFactor(t, 'transfer-review');
await tapFactor(t, 'transfer-confirm');
+5 -8
View File
@@ -11,6 +11,8 @@ import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import 'package:sxchnge_app/main.dart';
import 'package:sxchnge_app/providers/locale.dart';
import 'destination_helpers.dart';
Future<void> tap(WidgetTester t, Finder f) async {
await t.ensureVisible(f);
await t.tap(f);
@@ -32,6 +34,7 @@ Future<DummyWalletService> open(
settlement: settlement,
initialLocale: locale,
);
await seedDestination(service.destinations);
await t.pumpWidget(
SxchngeApp(
identityService: identity,
@@ -78,10 +81,7 @@ void main() {
expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget);
await tap(t, find.text('Pay out').last);
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
await t.enterText(
find.byKey(const ValueKey('transfer-recipient')),
'Sample recipient',
);
await selectPayout(t);
await tap(t, find.byKey(const ValueKey('transfer-review')));
expect(find.text('Recipient receives'), findsOneWidget);
await tap(t, find.byKey(const ValueKey('transfer-confirm')));
@@ -101,10 +101,7 @@ void main() {
);
s.failNextOperation = true;
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10');
await t.enterText(
find.byKey(const ValueKey('transfer-recipient')),
'Sample recipient',
);
await selectPayout(t);
await tap(t, find.byKey(const ValueKey('transfer-review')));
await tap(t, find.byKey(const ValueKey('transfer-confirm')));
// Settlement is service-owned; refresh after the simulated processing period.