Add saved payout destination UX and service contracts (#54)
This commit is contained in:
1 parent
a5689876ff
commit
24af794112
49 files changed
+4687
-62
No files matched your search
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user