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

+225
View File
@@ -0,0 +1,225 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import 'paste.dart';
import '../generated/i18n/app_localizations.dart';
class BankFields extends StatefulWidget {
const BankFields({
super.key,
required this.onChanged,
this.initial,
this.errorField,
});
final ValueChanged<DestinationDetails?> onChanged;
final RussianBankDestination? initial;
final String? errorField;
@override
State<BankFields> createState() => _BankFieldsState();
}
class _BankFieldsState extends State<BankFields> {
late final first = TextEditingController(text: widget.initial?.firstName);
late final last = TextEditingController(text: widget.initial?.lastName);
late final patronymic = TextEditingController(
text: widget.initial?.patronymic,
);
late final account = TextEditingController(text: widget.initial?.account);
final query = TextEditingController();
late final search = BankSearchProvider(
context.read<DestinationsProvider>().service,
);
late Bank? bank = widget.initial?.bank;
String? pastedHolder;
bool conflict = false;
Map<String, String> pasted = {};
bool get mismatch =>
bank != null &&
((pasted['bank'] != null && pasted['bank'] != bank!.name) ||
(pasted['correspondent'] != null &&
pasted['correspondent'] != bank!.correspondent));
void changed() => widget.onChanged(
bank == null
? null
: RussianBankDestination(
firstName: first.text,
lastName: last.text,
patronymic: patronymic.text,
bank: bank!,
account: account.text,
),
);
Future<void> paste() async {
final parsed = await showDialog<BankPaste>(
context: context,
builder: (_) => const BankPasteDialog(),
);
if (!mounted || parsed == null) return;
for (final entry in {
'firstName': first,
'lastName': last,
'patronymic': patronymic,
'account': account,
}.entries) {
if (parsed.conflicts.contains(entry.key)) {
entry.value.clear();
} else if (parsed.values[entry.key] case final String value) {
entry.value.text = value;
}
}
setState(() {
pasted = parsed.values;
conflict = parsed.conflicts.isNotEmpty;
pastedHolder = parsed.values['holder'];
});
if (parsed.conflicts.contains('bik')) {
setState(() => bank = null);
}
if (parsed.values['bik'] case final String bik) {
query.text = bik;
search.search(bik);
setState(() => bank = null);
}
changed();
}
@override
void dispose() {
for (final c in [first, last, patronymic, account, query]) {
c.dispose();
}
search.dispose();
super.dispose();
}
Widget field(
String label,
TextEditingController c, {
String? id,
bool numeric = false,
String? error,
}) => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: TextField(
key: id == null ? null : ValueKey(id),
controller: c,
keyboardType: numeric ? TextInputType.number : TextInputType.name,
decoration: InputDecoration(labelText: label, errorText: error),
onChanged: (_) => changed(),
),
);
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l.dHolder, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
LayoutBuilder(
builder: (context, b) {
final a = field(
l.dFirst,
first,
id: 'destination-first',
error: widget.errorField == 'holder' ? l.dInvalid : null,
);
final z = field(l.dLast, last, id: 'destination-last');
return b.maxWidth >= 480
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: a),
const SizedBox(width: 12),
Expanded(child: z),
],
)
: Column(children: [a, z]);
},
),
field(l.dPatronymic, patronymic),
if (pastedHolder != null) SelectableText(pastedHolder!),
if (conflict) Text(l.dPasteConflict),
Text(l.dBankDetails, style: Theme.of(context).textTheme.titleMedium),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: paste,
icon: const Icon(Icons.content_paste),
label: Text(l.dPaste),
),
),
ListenableBuilder(
listenable: search,
builder: (context, _) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (bank == null) ...[
TextField(
controller: query,
decoration: InputDecoration(labelText: l.dBankSearch),
onChanged: search.search,
),
if (search.loading) const LinearProgressIndicator(),
for (final b in search.results)
ListTile(
title: Text(b.name),
subtitle: Text('${b.bik} · ${b.city}'),
onTap: () {
setState(() => bank = b);
changed();
},
),
if (search.error != null)
Text(
search.error == DestinationIssue.unavailable
? l.dBankFailure
: l.dBankUnknown,
),
if (search.error == DestinationIssue.unavailable)
TextButton(
onPressed: () => search.search(query.text),
child: Text(l.wRefresh),
),
] else ...[
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(bank!.name),
subtitle: Text('${l.dBik} ${bank!.bik}'),
trailing: TextButton(
onPressed: () {
setState(() => bank = null);
changed();
},
child: Text(l.dChangeBank),
),
),
ExpansionTile(
title: Text(l.dBankDetails),
children: [
SelectableText(
'${l.dCorrespondent}: ${bank!.correspondent}',
),
],
),
if (!bank!.supported) Text(l.dUnsupported),
],
],
),
),
const SizedBox(height: 16),
field(
l.dAccount,
account,
id: 'destination-account',
numeric: true,
error: widget.errorField == 'account' ? l.dInvalid : null,
),
],
);
}
}
+33
View File
@@ -0,0 +1,33 @@
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../generated/i18n/app_localizations.dart';
String destinationType(AppLocalizations l, DestinationType type) =>
switch (type) {
DestinationType.crypto => l.dCrypto,
DestinationType.card => l.dCard,
DestinationType.russianBank => l.dBank,
};
String destinationIssue(AppLocalizations l, DestinationIssue issue) =>
switch (issue) {
DestinationIssue.invalid => l.dInvalid,
DestinationIssue.unavailable => l.dTemporary,
DestinationIssue.uncertain => l.dUncertain,
DestinationIssue.conflict => l.dConflict,
DestinationIssue.removed => l.dRemoved,
DestinationIssue.expired => l.dCardExpired,
DestinationIssue.bankChanged => l.dBankChanged,
DestinationIssue.authorization => l.dAuthorization,
DestinationIssue.unsupported => l.dUnsupported,
DestinationIssue.duplicate => l.dDuplicate,
DestinationIssue.replacementRequired => l.dReplacementRequired,
};
String eligibilityText(AppLocalizations l, PayoutEligibility e) =>
e.reason != null
? destinationIssue(l, e.reason!)
: switch (e.state) {
EligibilityState.eligible => l.dEligible,
EligibilityState.temporarilyUnavailable => l.dTemporary,
EligibilityState.requiresAction => l.dAction,
EligibilityState.unsupported => l.dUnsupported,
};
+128
View File
@@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../generated/i18n/app_localizations.dart';
class CryptoFields extends StatefulWidget {
const CryptoFields({
super.key,
required this.capabilities,
required this.onChanged,
this.initial,
this.errorField,
});
final DestinationCapabilities capabilities;
final ValueChanged<DestinationDetails?> onChanged;
final CryptoDestination? initial;
final String? errorField;
@override
State<CryptoFields> createState() => _CryptoFieldsState();
}
class _CryptoFieldsState extends State<CryptoFields> {
late final address = TextEditingController(text: widget.initial?.address);
late final memo = TextEditingController(text: widget.initial?.memo);
late String? assetId = widget.initial?.assetId;
late String? networkId = widget.initial?.networkId;
void changed() {
final a = widget.capabilities.assets
.where((a) => a.id == assetId && a.networkId == networkId)
.firstOrNull;
widget.onChanged(
a == null
? null
: CryptoDestination(
assetId: a.id,
networkId: a.networkId,
address: address.text,
memo: memo.text,
assetLabel: a.label,
networkLabel: a.networkLabel,
),
);
}
@override
void dispose() {
address.dispose();
memo.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final assets = widget.capabilities.assets;
final selected = assets
.where((a) => a.id == assetId && a.networkId == networkId)
.firstOrNull;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonFormField<String>(
key: ValueKey('asset-$assetId'),
initialValue: assets.any((a) => a.id == assetId) ? assetId : null,
isExpanded: true,
decoration: InputDecoration(labelText: l.dAsset),
items: [
for (final a in assets)
DropdownMenuItem(value: a.id, child: Text(a.label)),
],
onChanged: (v) {
setState(() {
assetId = v;
networkId = null;
memo.clear();
});
changed();
},
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
key: ValueKey('network-$assetId-$networkId'),
initialValue:
assets.any((a) => a.id == assetId && a.networkId == networkId)
? networkId
: null,
isExpanded: true,
decoration: InputDecoration(labelText: l.dNetwork),
items: [
for (final a in assets.where((a) => a.id == assetId))
DropdownMenuItem(value: a.networkId, child: Text(a.networkLabel)),
],
onChanged: (v) {
setState(() => networkId = v);
changed();
},
),
const SizedBox(height: 16),
TextField(
key: const ValueKey('destination-address'),
controller: address,
autocorrect: false,
enableSuggestions: false,
decoration: InputDecoration(
labelText: l.dAddress,
errorText: widget.errorField == 'address' ? l.dInvalid : null,
),
onChanged: (_) => changed(),
),
if (selected?.memoSupported == true) ...[
const SizedBox(height: 16),
TextField(
key: const ValueKey('destination-memo'),
controller: memo,
decoration: InputDecoration(
labelText: l.dMemo,
errorText: widget.errorField == 'memo' ? l.dInvalid : null,
),
onChanged: (_) => changed(),
),
],
const SizedBox(height: 12),
Text(l.dCryptoHelp),
],
);
}
}
+161
View File
@@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import 'copy.dart';
import 'details.dart';
import 'form.dart';
import 'nickname.dart';
import '../generated/i18n/app_localizations.dart';
class DestinationDetailPage extends StatefulWidget {
const DestinationDetailPage({super.key, required this.destination});
final SavedDestination destination;
@override
State<DestinationDetailPage> createState() => _DestinationDetailPageState();
}
class _DestinationDetailPageState extends State<DestinationDetailPage> {
late final p = DestinationManagementProvider(
context.read<DestinationsProvider>().service,
widget.destination,
);
late final nickname = TextEditingController(
text: widget.destination.nickname,
);
Future<void> remove() async {
final l = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l.dRemoveTitle),
content: Text(l.dRemoveHelp),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(l.wStay),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(l.dRemove),
),
],
),
);
if (!mounted || confirmed != true) return;
await p.remove();
if (mounted && p.removed) Navigator.pop(context);
}
@override
void dispose() {
p.dispose();
nickname.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
return ListenableBuilder(
listenable: p,
builder: (context, _) => Scaffold(
appBar: AppBar(title: Text(l.dDetails)),
body: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 120),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DestinationDetailsView(details: p.destination.details),
const SizedBox(height: 24),
DestinationNickname(
controller: nickname,
enabled: !p.busy,
onChanged: (_) {},
),
if (p.busy) const LinearProgressIndicator(),
if (p.error != null) ...[
Semantics(
liveRegion: true,
child: Text(destinationIssue(l, p.error!)),
),
TextButton(
onPressed: p.busy ? null : p.refresh,
child: Text(l.dReload),
),
],
const SizedBox(height: 16),
FilledButton(
onPressed: p.busy
? null
: () async {
await p.rename(nickname.text);
if (context.mounted && p.error == null) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.dSaved)));
}
},
child: Text(l.dRename),
),
if (p.destination.details is CardDestination &&
context
.read<DestinationsProvider>()
.capabilities
?.cardRecovery ==
true)
OutlinedButton(
onPressed: p.busy
? null
: () async {
final restored =
await Navigator.push<DestinationInput>(
context,
MaterialPageRoute(
builder: (_) => DestinationFormPage(
initial: p.destination.details,
restoring: p.destination.input,
),
),
);
if (mounted && restored != null) {
await p.refresh();
}
},
child: Text(l.dUpdateAuthorization),
),
TextButton(
onPressed: p.busy
? null
: () async {
await Navigator.push<DestinationInput>(
context,
MaterialPageRoute(
builder: (_) => DestinationFormPage(
initial: p.destination.details,
replacing: p.destination.id,
),
),
);
},
child: Text(l.dReplace),
),
TextButton(
onPressed: p.busy ? null : remove,
child: Text(l.dRemove),
),
],
),
),
),
),
),
);
}
}
@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../generated/i18n/app_localizations.dart';
/// Structured review renders the immutable service snapshot, never a nickname.
class DestinationDetailsView extends StatelessWidget {
const DestinationDetailsView({super.key, required this.details});
final DestinationDetails details;
Widget field(String label, String value) => Padding(
padding: const EdgeInsets.only(top: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [Text(label), SelectableText(value)],
),
);
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: switch (details) {
CryptoDestination d => [
field(l.dAsset, d.assetLabel),
field(l.dNetwork, d.networkLabel),
field(l.dAddress, d.address),
field(l.dMemo, d.memo.isEmpty ? l.dNoMemo : d.memo),
],
CardDestination d => [field(l.dCard, d.display)],
RussianBankDestination d => [
field(l.dHolder, d.holder),
field(l.dBankSearch, d.bank.name),
field(l.dBik, d.bank.bik),
field(l.dAccount, d.account),
field(l.dCorrespondent, d.bank.correspondent),
],
},
);
}
}
+292
View File
@@ -0,0 +1,292 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import 'bank.dart';
import 'copy.dart';
import 'crypto.dart';
import 'nickname.dart';
import '../generated/i18n/app_localizations.dart';
class DestinationFormPage extends StatefulWidget {
const DestinationFormPage({
super.key,
this.payoutContext,
this.initial,
this.replacing,
this.restoring,
});
final PayoutContext? payoutContext;
final DestinationDetails? initial;
final String? replacing;
final SavedReference? restoring;
@override
State<DestinationFormPage> createState() => _DestinationFormPageState();
}
class _DestinationFormPageState extends State<DestinationFormPage> {
late final p = DestinationFormProvider(
context.read<DestinationsProvider>().service,
context.read<AnalyticsService>(),
context: widget.payoutContext,
initial: widget.initial,
replacing: widget.replacing,
restoring: widget.restoring,
);
late DestinationType? type = widget.initial?.type;
final nickname = TextEditingController();
final focus = FocusNode();
Future<void> proceed({
bool withoutSaving = false,
bool existing = false,
}) async {
if (existing) {
await p.useExisting();
} else {
await p.proceed(withoutSaving: withoutSaving);
}
if (!mounted) return;
if (p.result != null) {
final l = AppLocalizations.of(context)!;
if (p.saved?.outcome == SaveOutcome.created || widget.restoring != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
widget.restoring == null ? l.dSaved : l.dAuthorizationUpdated,
),
),
);
}
Navigator.pop(context, p.result);
return;
}
focus.requestFocus();
}
@override
void dispose() {
p.dispose();
nickname.dispose();
focus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final caps = context.watch<DestinationsProvider>().capabilities;
return ListenableBuilder(
listenable: p,
builder: (context, _) => PopScope(
canPop: !p.busy,
child: Scaffold(
appBar: AppBar(title: Text(l.dAdd)),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (caps?.simulated == true) ...[
Text(l.dDemo),
const SizedBox(height: 16),
],
Text(l.dOwn),
const SizedBox(height: 20),
if (caps == null || caps.types.isEmpty)
Text(l.dNoCapabilities)
else ...[
DropdownButtonFormField<DestinationType>(
initialValue: caps.types.contains(type) ? type : null,
isExpanded: true,
decoration: InputDecoration(labelText: l.dType),
items: [
for (final t in caps.types)
DropdownMenuItem(
value: t,
child: Text(
destinationType(l, t),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
onChanged:
p.busy || p.uncertain || widget.restoring != null
? null
: (v) {
setState(() => type = v);
p.change(null);
},
),
const SizedBox(height: 20),
AbsorbPointer(
absorbing: p.busy || p.uncertain,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (type == DestinationType.crypto)
CryptoFields(
capabilities: caps,
initial: widget.initial is CryptoDestination
? widget.initial as CryptoDestination
: null,
errorField: p.errorField,
onChanged: p.change,
),
if (type == DestinationType.russianBank)
BankFields(
initial:
widget.initial is RussianBankDestination
? widget.initial as RussianBankDestination
: null,
errorField: p.errorField,
onChanged: p.change,
),
if (type == DestinationType.card &&
caps.simulated) ...[
Text(l.dCardDemo),
const SizedBox(height: 16),
RadioGroup<String>(
groupValue: p.details is CardDestination
? (p.details as CardDestination).capture
: null,
onChanged: (value) {
if (value != null) {
p.change(
CardDestination(
capture: value,
brand: 'Demo card',
last4: '4242',
expiryMonth: 12,
expiryYear: 2030,
),
);
}
},
child: Column(
children: [
RadioListTile<String>(
value: 'demo-card-ready',
title: Text(l.dCardReady),
),
RadioListTile<String>(
value: 'demo-card-recover',
title: Text(l.dCardRecover),
),
],
),
),
],
if (type == DestinationType.card && !caps.simulated)
Text(l.dNoCapabilities),
if (widget.payoutContext != null) ...[
const SizedBox(height: 16),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: p.save,
onChanged: (v) => p.preferences(save: v),
title: Text(l.dSaveFuture),
),
],
if (widget.restoring == null &&
(widget.payoutContext == null || p.save)) ...[
const SizedBox(height: 16),
DestinationNickname(
controller: nickname,
enabled: !p.busy && !p.uncertain,
onChanged: (v) => p.preferences(nickname: v),
),
],
],
),
),
const SizedBox(height: 16),
Focus(
focusNode: focus,
skipTraversal: true,
child: Semantics(
liveRegion: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (p.error != null)
Text(
p.uncertain
? l.dUncertain
: p.errorField != null
? l.dInvalid
: '${p.savingFailed ? '${l.dSaveFailed}\n' : ''}${destinationIssue(l, p.error!)}',
key: const ValueKey('destination-error'),
),
if (p.eligibility != null &&
!p.eligibility!.eligible)
Text(
'${p.saved != null ? '${l.dSavedIneligible}\n' : ''}${eligibilityText(l, p.eligibility!)}',
),
if (p.saved?.outcome ==
SaveOutcome.duplicate) ...[
Text(l.dDuplicate),
TextButton(
onPressed: p.busy
? null
: () => proceed(existing: true),
child: Text(l.dUseExisting),
),
],
if (p.saved?.outcome == SaveOutcome.created &&
p.result == null)
Text(l.dSaved),
],
),
),
),
if (p.busy) const LinearProgressIndicator(),
if (p.uncertain)
TextButton(
onPressed: p.busy ? null : () => proceed(),
child: Text(l.dCheckSave),
),
if (p.savingFailed && widget.payoutContext != null)
TextButton(
onPressed: p.busy
? null
: () => proceed(withoutSaving: true),
child: Text(l.dWithoutSave),
),
if (p.save && widget.payoutContext != null)
Text(l.dSaveHelp),
const SizedBox(height: 12),
FilledButton(
key: const ValueKey('destination-continue'),
onPressed: p.busy || p.details == null
? null
: () => proceed(),
child: Text(
widget.restoring != null
? l.dUpdateAuthorization
: widget.payoutContext == null
? l.dSave
: p.error != null && !p.uncertain
? l.dRetrySave
: l.dContinue,
),
),
],
],
),
),
),
),
),
),
);
}
}
+165
View File
@@ -0,0 +1,165 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import 'copy.dart';
import 'detail.dart';
import 'form.dart';
import '../generated/i18n/app_localizations.dart';
class DestinationsPage extends StatefulWidget {
const DestinationsPage({super.key, this.payoutContext});
final PayoutContext? payoutContext;
@override
State<DestinationsPage> createState() => _DestinationsPageState();
}
class _DestinationsPageState extends State<DestinationsPage> {
String query = '';
late final selection = DestinationSelectionProvider(
context.read<DestinationsProvider>().service,
widget.payoutContext ?? const PayoutContext(''),
);
Future<void> refresh() async {
final p = context.read<DestinationsProvider>();
await p.refresh();
if (!mounted) return;
await selection.assess(p.methods);
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) refresh();
});
}
Future<void> add() async {
final result = await Navigator.push<DestinationInput>(
context,
MaterialPageRoute(
builder: (_) =>
DestinationFormPage(payoutContext: widget.payoutContext),
),
);
if (!mounted) return;
if (result != null && widget.payoutContext != null) {
Navigator.pop(context, result);
} else {
await refresh();
}
}
Future<void> choose(SavedDestination d) async {
if (widget.payoutContext == null) {
await Navigator.push<void>(
context,
MaterialPageRoute(
builder: (_) => DestinationDetailPage(destination: d),
),
);
if (mounted) await refresh();
} else {
final ref = await selection.select(d);
if (mounted && ref != null) Navigator.pop(context, ref);
}
}
@override
void dispose() {
selection.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final p = context.watch<DestinationsProvider>();
return Scaffold(
appBar: AppBar(
title: Text(widget.payoutContext == null ? l.dTitle : l.dChoose),
actions: [
IconButton(
tooltip: l.dReload,
onPressed: p.loading ? null : refresh,
icon: const Icon(Icons.refresh),
),
],
),
body: ListenableBuilder(
listenable: selection,
builder: (context, _) => ListView(
padding: const EdgeInsets.all(24),
children: [
if (p.capabilities?.simulated == true) ...[
Text(l.dDemo),
const SizedBox(height: 16),
],
FilledButton.icon(
onPressed: p.loading || (p.capabilities?.types.isEmpty ?? true)
? null
: add,
icon: const Icon(Icons.add),
label: Text(widget.payoutContext == null ? l.dAdd : l.dNew),
),
if (p.capabilities?.types.isEmpty == true)
Text(l.dManagementUnavailable),
const SizedBox(height: 20),
TextField(
decoration: InputDecoration(
labelText: l.dSearch,
prefixIcon: const Icon(Icons.search),
),
onChanged: (v) => setState(() => query = v),
),
if (p.loading || selection.busy) const LinearProgressIndicator(),
if (p.failed) Text(l.dUnavailable),
if (selection.error != null)
Semantics(
liveRegion: true,
child: Text(destinationIssue(l, selection.error!)),
),
if (widget.payoutContext != null &&
!p.loading &&
p.methods.isNotEmpty &&
!selection.assessments.values.any((a) => a.eligible))
Text(l.dNoEligible),
for (final type
in p.capabilities?.types ?? <DestinationType>[]) ...[
const SizedBox(height: 24),
Text(
destinationType(l, type),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
if (p.search(query, type).isEmpty && !p.loading && !p.failed)
Text(query.isEmpty ? l.dEmpty : l.dNoMatches),
for (final d in p.search(query, type))
Card(
child: ListTile(
contentPadding: const EdgeInsets.all(16),
title: Text(d.label),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (d.nickname.isNotEmpty) Text(d.details.display),
if (selection.assessments[d.id]
case final PayoutEligibility e)
Text(eligibilityText(l, e)),
],
),
trailing: const Icon(Icons.chevron_right),
onTap: selection.busy ? null : () => choose(d),
),
),
],
],
),
),
);
}
}
@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import '../generated/i18n/app_localizations.dart';
class DestinationNickname extends StatelessWidget {
const DestinationNickname({
super.key,
required this.controller,
required this.onChanged,
this.enabled = true,
});
final TextEditingController controller;
final ValueChanged<String> onChanged;
final bool enabled;
@override
Widget build(BuildContext context) => TextField(
key: const ValueKey('destination-nickname'),
controller: controller,
enabled: enabled,
maxLength: 80,
decoration: InputDecoration(
labelText: AppLocalizations.of(context)!.dNickname,
),
onChanged: onChanged,
);
}
+79
View File
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../generated/i18n/app_localizations.dart';
class BankPasteDialog extends StatefulWidget {
const BankPasteDialog({super.key});
@override
State<BankPasteDialog> createState() => _BankPasteDialogState();
}
class _BankPasteDialogState extends State<BankPasteDialog> {
final text = TextEditingController();
BankPaste? result;
@override
void dispose() {
text.clear();
text.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final labels = {
'firstName': l.dFirst,
'lastName': l.dLast,
'patronymic': l.dPatronymic,
'account': l.dAccount,
'bik': 'BIK',
'bank': l.dBankSearch,
'holder': l.dHolder,
'correspondent': l.dCorrespondent,
};
return AlertDialog(
title: Text(l.dPaste),
content: SizedBox(
width: 480,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Text(l.dPasteHelp),
const SizedBox(height: 12),
TextField(
controller: text,
maxLines: 6,
decoration: InputDecoration(labelText: l.dBankDetails),
onChanged: (_) => setState(() => result = null),
),
if (result != null) ...[
const SizedBox(height: 12),
if (result!.conflicts.isNotEmpty) Text(l.dPasteConflict),
for (final e in result!.values.entries)
Text('${labels[e.key]}: ${e.value}'),
],
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l.wClose),
),
FilledButton(
onPressed: result == null
? () => setState(() => result = BankPaste.parse(text.text))
: result!.values.isEmpty
? null
: () => Navigator.pop(context, result),
child: Text(result == null ? l.dPasteReview : l.dPasteApply),
),
],
);
}
}
+91 -1
View File
@@ -289,5 +289,95 @@
"type": "int"
}
}
}
},
"dTitle": "Payout methods",
"dAdd": "Add payout method",
"dChoose": "Payout destination",
"dNew": "Use a new destination",
"dSave": "Save payout method",
"dContinue": "Continue",
"dSaveFuture": "Save for future payouts",
"dSaveHelp": "Continue will also save this destination.",
"dNickname": "Name for this method (optional)",
"dSaved": "Payout method saved.",
"dSaveFailed": "Couldn’t save these details. Your input is still here.",
"dRetrySave": "Retry saving",
"dWithoutSave": "Continue without saving",
"dUncertain": "We couldn’t confirm saving. This method may already be saved.",
"dCheckSave": "Check save status",
"dDuplicate": "This destination is already saved. Use the existing method.",
"dUseExisting": "Use existing method",
"dCrypto": "Crypto address",
"dCard": "Bank card",
"dBank": "Russian bank account",
"dType": "Destination type",
"dSearch": "Search saved methods",
"dEmpty": "No saved methods in this category.",
"dNoMatches": "No matching methods.",
"dEligible": "Available",
"dTemporary": "Temporarily unavailable for this payout.",
"dAction": "Action required",
"dUnsupported": "This method isn’t supported for this payout.",
"dUnavailable": "Couldn’t load methods. Try again.",
"dNoCapabilities": "No destination types are currently available.",
"dDemo": "Demo destinations — saving lasts until reload. No real payout or card collection is connected.",
"dAsset": "Receiving asset",
"dNetwork": "Network",
"dAddress": "Wallet address",
"dMemo": "Memo / destination tag",
"dNoMemo": "No memo / tag",
"dCryptoHelp": "Use the network supported by your receiving wallet. A valid address format does not guarantee delivery.",
"dHolder": "Account holder",
"dFirst": "First name",
"dLast": "Last name",
"dPatronymic": "Patronymic (optional)",
"dBankDetails": "Bank details",
"dBankSearch": "Bank name or BIK",
"dAccount": "20-digit account number",
"dCorrespondent": "Correspondent account",
"dChangeBank": "Change bank",
"dBankUnknown": "Bank not found. Check the name or BIK.",
"dBankFailure": "Bank directory is unavailable. Your input is preserved.",
"dBankChanged": "Bank details changed. Review them and add a replacement destination.",
"dPaste": "Paste bank details",
"dPasteHelp": "Paste labelled bank details. Review recognized values before applying them.",
"dPasteReview": "Review recognized details",
"dPasteApply": "Use these details",
"dPasteConflict": "Conflicting values were found. Correct the affected fields in the form.",
"dInvalid": "Check the entered details and correct the highlighted field.",
"dCardDemo": "Choose a simulated secure card capture. Do not enter a real card number.",
"dCardReady": "Demo card •••• 4242",
"dCardRecover": "Demo card requiring renewed authorization",
"dCardExpired": "This card has expired. Add a replacement card.",
"dAuthorization": "Authorization needs updating. Follow the service-supported recovery or add a replacement.",
"dRemoved": "This method was removed. Your draft is preserved. Choose a destination to continue.",
"dConflict": "This method changed in another session. Refresh and review the latest version.",
"dRename": "Rename",
"dRemove": "Remove",
"dReplace": "Add replacement",
"dRemoveTitle": "Remove payout method?",
"dRemoveHelp": "Removed methods cannot be used for new payouts. Submitted payouts and required financial records are retained.",
"dDetails": "Destination details",
"dReload": "Refresh methods",
"dSelect": "Select destination",
"dQuoteChanged": "Payout terms changed. Your draft is preserved. Select a destination and review a new quote.",
"dOwn": "Use a wallet, card, or account that belongs to you.",
"dSavedIneligible": "Saved. This method is unavailable for the current payout.",
"dSaveFailedLabel": "Saving failed",
"dNoEligible": "No methods are available for this payout. You can add a destination or refresh.",
"dDemoTerms": "Demo receiving amounts use a fixed 1:1 ratio. No real payout is made.",
"dTiming": "Expected processing time: {seconds} seconds",
"@dTiming": {
"placeholders": {
"seconds": {
"type": "int"
}
}
},
"dBik": "BIK",
"dBankMismatch": "Pasted bank details differ from the selected bank. Check the details before continuing.",
"dManagementUnavailable": "Saving payout methods isn’t available yet. You can still use your configured payout destination.",
"dUpdateAuthorization": "Update authorization",
"dAuthorizationUpdated": "Authorization updated.",
"dReplacementRequired": "Couldn’t confirm this is the same card. Add a replacement destination."
}
+91 -1
View File
@@ -289,5 +289,95 @@
"type": "int"
}
}
}
},
"dTitle": "Способы вывода",
"dAdd": "Добавить способ вывода",
"dChoose": "Реквизиты для вывода",
"dNew": "Использовать новые реквизиты",
"dSave": "Сохранить способ вывода",
"dContinue": "Продолжить",
"dSaveFuture": "Сохранить для будущих выводов",
"dSaveHelp": "При продолжении реквизиты также будут сохранены.",
"dNickname": "Название способа вывода (необязательно)",
"dSaved": "Способ вывода сохранён.",
"dSaveFailed": "Не удалось сохранить реквизиты. Введённые данные остались в форме.",
"dRetrySave": "Повторить сохранение",
"dWithoutSave": "Продолжить без сохранения",
"dUncertain": "Не удалось подтвердить сохранение. Возможно, способ уже сохранён.",
"dCheckSave": "Проверить статус сохранения",
"dDuplicate": "Эти реквизиты уже сохранены. Используйте существующий способ.",
"dUseExisting": "Использовать существующий способ",
"dCrypto": "Криптоадрес",
"dCard": "Банковская карта",
"dBank": "Российский банковский счёт",
"dType": "Тип реквизитов",
"dSearch": "Поиск сохранённых способов",
"dEmpty": "В этой категории нет сохранённых способов.",
"dNoMatches": "Подходящие способы не найдены.",
"dEligible": "Доступно",
"dTemporary": "Временно недоступно для этого вывода.",
"dAction": "Требуется действие",
"dUnsupported": "Этот способ не поддерживается для данного вывода.",
"dUnavailable": "Не удалось загрузить способы. Повторите попытку.",
"dNoCapabilities": "Сейчас нет доступных типов реквизитов.",
"dDemo": "Демонстрационные реквизиты — сохранение действует до перезагрузки. Реальный вывод и ввод карт не подключены.",
"dAsset": "Получаемый актив",
"dNetwork": "Сеть",
"dAddress": "Адрес кошелька",
"dMemo": "Memo / тег назначения",
"dNoMemo": "Без memo / тега",
"dCryptoHelp": "Выберите сеть, поддерживаемую кошельком получателя. Верный формат адреса не гарантирует зачисление.",
"dHolder": "Владелец счёта",
"dFirst": "Имя",
"dLast": "Фамилия",
"dPatronymic": "Отчество (необязательно)",
"dBankDetails": "Банковские реквизиты",
"dBankSearch": "Название банка или БИК",
"dAccount": "20-значный номер счёта",
"dCorrespondent": "Корреспондентский счёт",
"dChangeBank": "Изменить банк",
"dBankUnknown": "Банк не найден. Проверьте название или БИК.",
"dBankFailure": "Справочник банков недоступен. Введённые данные сохранены.",
"dBankChanged": "Банковские реквизиты изменились. Проверьте их и добавьте новые реквизиты.",
"dPaste": "Вставить банковские реквизиты",
"dPasteHelp": "Вставьте реквизиты с названиями полей. Проверьте распознанные значения перед применением.",
"dPasteReview": "Проверить распознанные реквизиты",
"dPasteApply": "Использовать эти реквизиты",
"dPasteConflict": "Найдены противоречивые значения. Исправьте соответствующие поля в форме.",
"dInvalid": "Проверьте введённые реквизиты и исправьте выделенное поле.",
"dCardDemo": "Выберите демонстрационный результат безопасного ввода карты. Не вводите настоящий номер карты.",
"dCardReady": "Демо-карта •••• 4242",
"dCardRecover": "Демо-карта с истёкшей авторизацией",
"dCardExpired": "Срок действия карты истёк. Добавьте новую карту.",
"dAuthorization": "Необходимо обновить авторизацию. Используйте доступное восстановление или добавьте новые реквизиты.",
"dRemoved": "Этот способ удалён. Черновик сохранён. Выберите реквизиты для продолжения.",
"dConflict": "Этот способ изменён в другой сессии. Обновите и проверьте актуальную версию.",
"dRename": "Переименовать",
"dRemove": "Удалить",
"dReplace": "Добавить новые реквизиты",
"dRemoveTitle": "Удалить способ вывода?",
"dRemoveHelp": "Удалённые способы недоступны для новых выводов. Отправленные заявки и обязательные финансовые записи сохраняются.",
"dDetails": "Реквизиты",
"dReload": "Обновить способы",
"dSelect": "Выбрать реквизиты",
"dQuoteChanged": "Условия вывода изменились. Черновик сохранён. Выберите реквизиты и проверьте новую котировку.",
"dOwn": "Используйте принадлежащий вам кошелёк, карту или счёт.",
"dSavedIneligible": "Сохранено. Этот способ недоступен для текущего вывода.",
"dSaveFailedLabel": "Ошибка сохранения",
"dNoEligible": "Для этого вывода нет доступных способов. Добавьте реквизиты или обновите список.",
"dDemoTerms": "Демонстрационные суммы зачисления рассчитаны по фиксированному курсу 1:1. Реальный вывод не выполняется.",
"dTiming": "Ожидаемое время обработки: {seconds} сек.",
"@dTiming": {
"placeholders": {
"seconds": {
"type": "int"
}
}
},
"dBik": "БИК",
"dBankMismatch": "Вставленные реквизиты отличаются от выбранного банка. Проверьте их перед продолжением.",
"dManagementUnavailable": "Сохранение способов вывода пока недоступно. Вы можете использовать ранее настроенные реквизиты для вывода.",
"dUpdateAuthorization": "Обновить авторизацию",
"dAuthorizationUpdated": "Авторизация обновлена.",
"dReplacementRequired": "Не удалось подтвердить, что это та же карта. Добавьте новые реквизиты."
}
+12
View File
@@ -37,6 +37,7 @@ class SxchngeApp extends StatefulWidget {
this.identityService,
this.limitsService,
this.walletService,
this.destinationService,
this.twoFactorService,
this.photoService = const FilePhotoService(),
this.loginService = const DummyLoginService(),
@@ -49,6 +50,7 @@ class SxchngeApp extends StatefulWidget {
final IdentityService? identityService;
final LimitsService? limitsService;
final WalletService? walletService;
final DestinationService? destinationService;
final TwoFactorService? twoFactorService;
final PhotoService photoService;
final LoginService loginService;
@@ -89,10 +91,17 @@ class _SxchngeAppState extends State<SxchngeApp> {
initialLocale: _locale.locale.languageCode,
factors: _authFactors,
));
late final _destinationService =
widget.destinationService ??
(_walletService.isSimulated && _walletService is DummyWalletService
? _walletService.destinations
: const UnavailableDestinationService());
late final _destinations = DestinationsProvider(_destinationService);
late final _wallet = WalletProvider(
_walletService,
_analytics,
factors: _factors,
destinations: _destinationService,
);
late final _limits =
widget.limitsService ??
@@ -131,6 +140,7 @@ class _SxchngeAppState extends State<SxchngeApp> {
if (customer != _walletCustomer) {
_walletCustomer = customer;
_wallet.reset();
_destinations.reset();
}
final path = _router.routeInformationProvider.value.uri.path;
if (path == '/' && _previousPath != '/' && _previousPath != '/two-factor') {
@@ -145,6 +155,7 @@ class _SxchngeAppState extends State<SxchngeApp> {
_router.dispose();
_journeys.dispose();
_wallet.dispose();
_destinations.dispose();
if (_walletService is BackendWalletService) {
_walletService.close();
}
@@ -161,6 +172,7 @@ class _SxchngeAppState extends State<SxchngeApp> {
Widget build(BuildContext context) => MultiProvider(
providers: [
ChangeNotifierProvider.value(value: _wallet),
ChangeNotifierProvider.value(value: _destinations),
ChangeNotifierProvider.value(value: _security),
Provider<TwoFactorService>.value(value: _factors),
Provider<JourneyRecorder>.value(value: _journeys),
+7
View File
@@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../auth/page.dart';
import '../destinations/list.dart';
import '../kyc/page.dart';
import '../security/challenge.dart';
import '../verification/page.dart';
@@ -82,6 +83,12 @@ GoRouter createAppRouter({bool Function()? authenticated}) {
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsPage(),
routes: [
GoRoute(
path: 'payout-methods',
builder: (context, state) => const DestinationsPage(),
),
],
),
GoRoute(
path: '/top-up',
+1
View File
@@ -48,6 +48,7 @@ String fundsText(
_ => l.wFailedOutgoing,
};
String walletError(AppLocalizations l, WalletError e) => switch (e) {
WalletError.destinationChanged => l.dQuoteChanged,
WalletError.invalidAmount => l.wInvalidAmount,
WalletError.insufficientFunds => l.wInsufficient,
WalletError.limitExceeded => l.wLimitError,
+9
View File
@@ -106,6 +106,15 @@ class SettingsPage extends StatelessWidget {
),
),
const SizedBox(height: 16),
Card(
child: ListTile(
leading: const Icon(Icons.account_balance_outlined),
title: Text(l.dTitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.push('/settings/payout-methods'),
),
),
const SizedBox(height: 16),
const SecuritySettings(),
const SizedBox(height: 20),
TextButton(
+16 -2
View File
@@ -1,3 +1,5 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
@@ -6,6 +8,8 @@ import 'package:provider/provider.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../destinations/details.dart';
import 'copy.dart';
import '../generated/i18n/app_localizations.dart';
@@ -49,13 +53,23 @@ class TransferSummary extends StatelessWidget {
q.request.kind == TransferKind.payout
? l.wRecipientGets
: l.wCredit,
money(context, b, q.credit),
q.payoutTerms == null
? money(context, b, q.credit)
: '${NumberFormat.decimalPatternDigits(locale: Localizations.localeOf(context).toLanguageTag(), decimalDigits: q.payoutTerms!.precision).format(q.payoutTerms!.amount / pow(10, q.payoutTerms!.precision))} ${q.payoutTerms!.assetLabel}',
large: true,
),
if (q.payoutTerms != null) ...[
const SizedBox(height: 12),
Text(l.dTiming(q.payoutTerms!.expectedSeconds)),
if (p.service.isSimulated) Text(l.dDemoTerms),
],
if (q.request.kind == TransferKind.payout) ...[
const Divider(height: 32),
Text(l.wRecipient),
SelectableText(q.request.recipient),
if (q.payoutSnapshot != null)
DestinationDetailsView(details: q.payoutSnapshot!)
else
SelectableText(q.request.recipient),
],
],
),
+86 -24
View File
@@ -11,6 +11,7 @@ import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../security/challenge.dart';
import '../destinations/list.dart';
import 'copy.dart';
import 'summary.dart';
@@ -32,7 +33,7 @@ class TransferPageState extends State<TransferPage> {
source: widget.source,
);
final _amount = TextEditingController();
final _recipient = TextEditingController();
final _focus = FocusNode();
final _content = GlobalKey();
Timer? _timer;
@@ -41,7 +42,13 @@ class TransferPageState extends State<TransferPage> {
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _provider.addListener(_changed);
if (mounted) {
_provider.addListener(_changed);
final destinations = context.read<DestinationsProvider>();
if (destinations.capabilities == null && !destinations.loading) {
unawaited(destinations.refresh());
}
}
});
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted && _provider.quote != null) setState(() {});
@@ -118,7 +125,7 @@ class TransferPageState extends State<TransferPage> {
_provider.removeListener(_changed);
_provider.dispose();
_amount.dispose();
_recipient.dispose();
_focus.dispose();
super.dispose();
}
@@ -134,14 +141,16 @@ class TransferPageState extends State<TransferPage> {
final accounts = wallet.data?.accounts ?? [];
final source = wallet.account(p.source);
final live = !wallet.service.isSimulated;
final destinations = context.watch<DestinationsProvider>();
final legacyPayout =
live &&
!(destinations.capabilities?.types.isNotEmpty ?? false) &&
destinations.methods.isEmpty;
final expiresIn =
p.quote?.expiresAt.difference(DateTime.now()).inSeconds ?? 0;
final unavailableMethod =
live &&
((widget.kind == TransferKind.payout &&
(source?.payoutMethod.isEmpty ?? true)) ||
(widget.kind == TransferKind.topUp &&
!(source?.canFund ?? false)));
((widget.kind == TransferKind.topUp && !(source?.canFund ?? false)));
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
@@ -235,7 +244,11 @@ class TransferPageState extends State<TransferPage> {
FilledButton(
key: const ValueKey('transfer-confirm'),
onPressed: p.busy ? null : _confirm,
child: Text(l.wConfirm),
child: Text(
widget.kind == TransferKind.payout
? '${l.wPayout} ${money(context, source!, p.quote!.debit)}'
: l.wConfirm,
),
),
TextButton(
onPressed: p.busy ? null : p.edit,
@@ -316,23 +329,66 @@ class TransferPageState extends State<TransferPage> {
),
if (widget.kind == TransferKind.payout) ...[
const SizedBox(height: 20),
if (live && source?.payoutMethod.isNotEmpty == true)
InputDecorator(
decoration: InputDecoration(
labelText: l.wRecipient,
if (legacyPayout) ...[
if (source?.payoutMethod.isNotEmpty == true)
OutlinedButton.icon(
key: const ValueKey(
'choose-configured-destination',
),
icon: const Icon(
Icons.account_balance_outlined,
),
label: Text(source!.payoutLabel),
onPressed: p.busy
? null
: () => p.change(
recipient: source.payoutMethod,
payoutLabel: source.payoutLabel,
),
),
child: Text(source!.payoutLabel),
),
if (!live)
TextField(
key: const ValueKey('transfer-recipient'),
controller: _recipient,
enabled: !p.busy,
decoration: InputDecoration(
labelText: l.wRecipient,
hintText: l.wRecipientHint,
if (source?.payoutMethod.isEmpty ?? true)
Text(l.wNoPayoutMethod),
] else
OutlinedButton.icon(
key: const ValueKey(
'choose-payout-destination',
),
onChanged: (v) => p.change(recipient: v),
icon: const Icon(
Icons.account_balance_wallet_outlined,
),
label: Text(
p.payoutDestination == null
? l.dSelect
: p.payoutLabel,
),
onPressed: p.busy
? null
: () async {
final selected =
await Navigator.push<
DestinationInput
>(
context,
MaterialPageRoute(
builder: (_) =>
DestinationsPage(
payoutContext:
PayoutContext(
p.source,
amount: source
?.parse(
p.amount,
),
),
),
),
);
if (!context.mounted ||
selected == null) {
return;
}
await p.selectPayout(selected);
},
),
],
if (unavailableMethod) ...[
@@ -346,7 +402,13 @@ class TransferPageState extends State<TransferPage> {
const SizedBox(height: 28),
FilledButton(
key: const ValueKey('transfer-review'),
onPressed: p.busy || unavailableMethod
onPressed:
p.busy ||
unavailableMethod ||
(widget.kind == TransferKind.payout &&
(legacyPayout
? p.recipient.isEmpty
: p.payoutDestination == null))
? null
: p.review,
child: Text(l.wReview),