Build exchange workspace and shared transaction services (#28)
This commit is contained in:
1 parent
52b7135eda
commit
c496da8a55
48 files changed
+4757
-120
No files matched your search
@@ -0,0 +1,245 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
|
||||
import 'activity.dart';
|
||||
import 'copy.dart';
|
||||
|
||||
import '../generated/i18n/app_localizations.dart';
|
||||
|
||||
class AccountsPage extends StatelessWidget {
|
||||
const AccountsPage({super.key, this.accountId});
|
||||
final String? accountId;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.watch<WalletProvider>();
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final t = Theme.of(context);
|
||||
final c = t.colorScheme;
|
||||
final accounts = p.data?.accounts ?? [];
|
||||
final selected = accountId == null
|
||||
? accounts.firstOrNull
|
||||
: p.account(accountId);
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1120),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(l.wHeadline, style: t.textTheme.displayMedium),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l.wRefresh,
|
||||
onPressed: p.loading ? null : p.refresh,
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(l.wSubheading, style: t.textTheme.bodyLarge),
|
||||
const SizedBox(height: 28),
|
||||
if (p.loading) const LinearProgressIndicator(),
|
||||
if (p.failed)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Text(l.wUnavailable, style: TextStyle(color: c.error)),
|
||||
),
|
||||
if (!p.loading && accounts.isEmpty) Text(l.wEmptyAccounts),
|
||||
if (accountId != null &&
|
||||
p.account(accountId) == null &&
|
||||
!p.loading)
|
||||
Text(l.wNotFound),
|
||||
if (selected != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
Text(selected.currency, style: t.textTheme.titleLarge),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
for (final kind in TransferKind.values)
|
||||
_action(
|
||||
context,
|
||||
kind,
|
||||
selected,
|
||||
primary:
|
||||
kind ==
|
||||
(selected.available == 0
|
||||
? TransferKind.topUp
|
||||
: TransferKind.exchange),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
LayoutBuilder(
|
||||
builder: (context, box) {
|
||||
final width = box.maxWidth >= 660
|
||||
? (box.maxWidth - 32) / 3
|
||||
: box.maxWidth;
|
||||
return Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
children: [
|
||||
for (var i = 0; i < accounts.length; i++)
|
||||
SizedBox(
|
||||
width: width,
|
||||
child: _AccountCard(
|
||||
account: accounts[i],
|
||||
selected: accounts[i].id == selected?.id,
|
||||
index: i,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 36),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l.wRecent, style: t.textTheme.headlineSmall),
|
||||
TextButton(
|
||||
onPressed: () => context.go('/activity'),
|
||||
child: Text(l.wSeeAll),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...?p.data?.operations
|
||||
.where(
|
||||
(o) =>
|
||||
accountId == null ||
|
||||
o.quote.request.source == accountId ||
|
||||
o.quote.request.destination == accountId,
|
||||
)
|
||||
.take(5)
|
||||
.map((o) => OperationTile(operation: o)),
|
||||
if (!(p.data?.operations.any(
|
||||
(o) =>
|
||||
accountId == null ||
|
||||
o.quote.request.source == accountId ||
|
||||
o.quote.request.destination == accountId,
|
||||
) ??
|
||||
false))
|
||||
Container(
|
||||
padding: const EdgeInsets.all(28),
|
||||
decoration: BoxDecoration(
|
||||
color: c.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Text(l.wEmptyActivity),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _action(
|
||||
BuildContext context,
|
||||
TransferKind kind,
|
||||
CurrencyAccount a, {
|
||||
required bool primary,
|
||||
}) {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
void go() => context.go(
|
||||
'/${kind.name == "topUp" ? "top-up" : kind.name}',
|
||||
extra: a.id,
|
||||
);
|
||||
return primary
|
||||
? FilledButton.icon(
|
||||
key: ValueKey('${kind.name}-${a.id}'),
|
||||
onPressed: go,
|
||||
icon: Icon(kindIcon(kind)),
|
||||
label: Text(kindText(l, kind)),
|
||||
)
|
||||
: FilledButton.tonalIcon(
|
||||
key: ValueKey('${kind.name}-${a.id}'),
|
||||
onPressed: go,
|
||||
icon: Icon(kindIcon(kind)),
|
||||
label: Text(kindText(l, kind)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AccountCard extends StatelessWidget {
|
||||
const _AccountCard({
|
||||
required this.account,
|
||||
required this.selected,
|
||||
required this.index,
|
||||
});
|
||||
final CurrencyAccount account;
|
||||
final bool selected;
|
||||
final int index;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final t = Theme.of(context);
|
||||
final c = t.colorScheme;
|
||||
final bg = selected
|
||||
? c.primary
|
||||
: index == 1
|
||||
? c.tertiaryContainer
|
||||
: c.primaryContainer;
|
||||
final fg = selected
|
||||
? c.onPrimary
|
||||
: index == 1
|
||||
? c.onTertiaryContainer
|
||||
: c.onPrimaryContainer;
|
||||
return Semantics(
|
||||
selected: selected,
|
||||
child: Material(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
child: InkWell(
|
||||
key: ValueKey('account-${account.id}'),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
onTap: () => context.go('/accounts/${account.id}'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
account.currency,
|
||||
style: t.textTheme.titleLarge?.copyWith(color: fg),
|
||||
),
|
||||
const Spacer(),
|
||||
Icon(
|
||||
selected
|
||||
? Icons.check_circle_outline
|
||||
: Icons.arrow_outward_rounded,
|
||||
color: fg,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(l.wAvailable, style: TextStyle(color: fg)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
money(context, account, account.available),
|
||||
key: ValueKey('balance-${account.id}'),
|
||||
style: t.textTheme.headlineMedium?.copyWith(color: fg),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
|
||||
import 'copy.dart';
|
||||
|
||||
import '../generated/i18n/app_localizations.dart';
|
||||
|
||||
class ActivityPage extends StatefulWidget {
|
||||
const ActivityPage({super.key});
|
||||
@override
|
||||
State<ActivityPage> createState() => _ActivityPageState();
|
||||
}
|
||||
|
||||
class _ActivityPageState extends State<ActivityPage> {
|
||||
TransferKind? _kind;
|
||||
OperationStatus? _status;
|
||||
String _search = '';
|
||||
String? _currency;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.watch<WalletProvider>();
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final list =
|
||||
p.data?.operations
|
||||
.where(
|
||||
(o) =>
|
||||
(_kind == null || o.quote.request.kind == _kind) &&
|
||||
(_status == null || o.status == _status) &&
|
||||
(_currency == null ||
|
||||
o.quote.request.source == _currency ||
|
||||
o.quote.request.destination == _currency) &&
|
||||
o.id.toLowerCase().contains(_search.toLowerCase()),
|
||||
)
|
||||
.toList() ??
|
||||
[];
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1000),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
l.wActivity,
|
||||
style: Theme.of(context).textTheme.displaySmall,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l.wRefresh,
|
||||
onPressed: p.loading ? null : p.refresh,
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: l.wSearch,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
),
|
||||
onChanged: (value) => setState(() => _search = value),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: Text(l.wAll),
|
||||
selected: _kind == null,
|
||||
onSelected: (_) => setState(() => _kind = null),
|
||||
),
|
||||
for (final k in TransferKind.values)
|
||||
ChoiceChip(
|
||||
label: Text(kindText(l, k)),
|
||||
selected: _kind == k,
|
||||
onSelected: (_) => setState(() => _kind = k),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final s in OperationStatus.values)
|
||||
FilterChip(
|
||||
label: Text(statusText(l, s)),
|
||||
selected: _status == s,
|
||||
onSelected: (value) =>
|
||||
setState(() => _status = value ? s : null),
|
||||
),
|
||||
for (final a in p.data?.accounts ?? <CurrencyAccount>[])
|
||||
FilterChip(
|
||||
label: Text(a.currency),
|
||||
selected: _currency == a.id,
|
||||
onSelected: (value) =>
|
||||
setState(() => _currency = value ? a.id : null),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (p.loading) const LinearProgressIndicator(),
|
||||
if (p.failed) Text(l.wUnavailable),
|
||||
if (list.isEmpty && !p.loading)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
(p.data?.operations.isEmpty ?? true)
|
||||
? l.wEmptyActivity
|
||||
: l.wNoMatches,
|
||||
),
|
||||
),
|
||||
for (final o in list) OperationTile(operation: o),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OperationTile extends StatelessWidget {
|
||||
const OperationTile({super.key, required this.operation});
|
||||
final WalletOperation operation;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.read<WalletProvider>();
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final o = operation;
|
||||
final a = p.account(o.quote.request.source);
|
||||
final to = p.account(o.quote.request.destination);
|
||||
final t = Theme.of(context);
|
||||
return Card(
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: () => context.push('/activity/${o.id}'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: t.colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
kindIcon(o.quote.request.kind),
|
||||
color: t.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
kindText(l, o.quote.request.kind),
|
||||
style: t.textTheme.titleMedium,
|
||||
),
|
||||
Text(
|
||||
'${a?.currency ?? ''}${to == null ? '' : ' → ${to.currency}'} · ${statusText(l, o.status)}',
|
||||
),
|
||||
Text(
|
||||
DateFormat.yMMMd(
|
||||
Localizations.localeOf(context).toLanguageTag(),
|
||||
).add_Hm().format(o.createdAt),
|
||||
style: t.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
a == null ? '' : money(context, a, o.quote.request.amount),
|
||||
textAlign: TextAlign.end,
|
||||
style: t.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
|
||||
import '../generated/i18n/app_localizations.dart';
|
||||
|
||||
String money(BuildContext context, CurrencyAccount account, int amount) =>
|
||||
NumberFormat.currency(
|
||||
locale: Localizations.localeOf(context).toLanguageTag(),
|
||||
name: account.currency,
|
||||
symbol: '${account.currency} ',
|
||||
decimalDigits: account.precision,
|
||||
).format(amount / account.scale);
|
||||
String kindText(AppLocalizations l, TransferKind kind) => switch (kind) {
|
||||
TransferKind.topUp => l.wTopUp,
|
||||
TransferKind.exchange => l.wExchange,
|
||||
TransferKind.payout => l.wPayout,
|
||||
};
|
||||
IconData kindIcon(TransferKind kind) => switch (kind) {
|
||||
TransferKind.topUp => Icons.add_rounded,
|
||||
TransferKind.exchange => Icons.swap_horiz_rounded,
|
||||
TransferKind.payout => Icons.north_east_rounded,
|
||||
};
|
||||
String statusText(AppLocalizations l, OperationStatus s) => switch (s) {
|
||||
OperationStatus.pending => l.wPending,
|
||||
OperationStatus.completed => l.wCompleted,
|
||||
OperationStatus.failed => l.wFailed,
|
||||
};
|
||||
String fundsText(AppLocalizations l, WalletOperation o) =>
|
||||
switch ((o.status, o.quote.request.kind)) {
|
||||
(OperationStatus.pending, TransferKind.topUp) => l.wPendingTopUp,
|
||||
(OperationStatus.pending, TransferKind.exchange) => l.wPendingExchange,
|
||||
(OperationStatus.pending, TransferKind.payout) => l.wPendingPayout,
|
||||
(OperationStatus.completed, TransferKind.topUp) => l.wCompleteTopUp,
|
||||
(OperationStatus.completed, TransferKind.exchange) => l.wCompleteExchange,
|
||||
(OperationStatus.completed, TransferKind.payout) => l.wCompletePayout,
|
||||
(OperationStatus.failed, TransferKind.topUp) => l.wFailedTopUp,
|
||||
_ => l.wFailedOutgoing,
|
||||
};
|
||||
String walletError(AppLocalizations l, WalletError e) => switch (e) {
|
||||
WalletError.invalidAmount => l.wInvalidAmount,
|
||||
WalletError.insufficientFunds => l.wInsufficient,
|
||||
WalletError.limitExceeded => l.wLimitError,
|
||||
WalletError.expiredQuote => l.wExpired,
|
||||
WalletError.invalidRecipient => l.wRecipientError,
|
||||
WalletError.unsupported => l.wUnsupported,
|
||||
WalletError.notFound => l.wNotFound,
|
||||
WalletError.uncertain => l.wUncertain,
|
||||
_ => l.wUnavailable,
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
|
||||
import '../kyc/limits.dart';
|
||||
|
||||
import '../generated/i18n/app_localizations.dart';
|
||||
|
||||
class LimitsPage extends StatefulWidget {
|
||||
const LimitsPage({super.key});
|
||||
@override
|
||||
State<LimitsPage> createState() => _LimitsPageState();
|
||||
}
|
||||
|
||||
class _LimitsPageState extends State<LimitsPage> {
|
||||
late final _provider = WorkspaceLimitsProvider(
|
||||
context.read<LimitsService>(),
|
||||
context.read<WalletProvider>(),
|
||||
);
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _provider.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_provider.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
return ChangeNotifierProvider<LimitsProvider>.value(
|
||||
value: _provider,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 720),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
l.wLimits,
|
||||
style: Theme.of(context).textTheme.displaySmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(l.wLimitsScope),
|
||||
const SizedBox(height: 24),
|
||||
const LimitsPanel(),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
await context.push('/identity');
|
||||
if (mounted) _provider.refresh();
|
||||
},
|
||||
child: Text(l.wIdentity),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _provider.refresh,
|
||||
child: Text(l.wRefresh),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
|
||||
import 'copy.dart';
|
||||
import 'summary.dart';
|
||||
|
||||
import '../generated/i18n/app_localizations.dart';
|
||||
|
||||
class OperationPage extends StatefulWidget {
|
||||
const OperationPage({super.key, required this.id});
|
||||
final String id;
|
||||
@override
|
||||
State<OperationPage> createState() => _OperationPageState();
|
||||
}
|
||||
|
||||
class _OperationPageState extends State<OperationPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) context.read<WalletProvider>().refresh();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.watch<WalletProvider>();
|
||||
final o = p.operation(widget.id);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final t = Theme.of(context);
|
||||
final c = t.colorScheme;
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 640),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => context.go('/activity'),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
label: Text(l.wActivity),
|
||||
),
|
||||
),
|
||||
if (p.failed)
|
||||
Text(l.wUnavailable, style: TextStyle(color: c.error)),
|
||||
if (o == null) ...[
|
||||
Text(p.loading ? l.wWorking : l.wNotFound),
|
||||
TextButton(
|
||||
onPressed: p.loading ? null : p.refresh,
|
||||
child: Text(l.wRefresh),
|
||||
),
|
||||
] else ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(28),
|
||||
decoration: BoxDecoration(
|
||||
color: o.status == OperationStatus.failed
|
||||
? c.errorContainer
|
||||
: c.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
o.status == OperationStatus.completed
|
||||
? Icons.check_circle_outline
|
||||
: o.status == OperationStatus.pending
|
||||
? Icons.schedule
|
||||
: Icons.info_outline,
|
||||
size: 40,
|
||||
color: o.status == OperationStatus.failed
|
||||
? c.onErrorContainer
|
||||
: c.onPrimaryContainer,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Semantics(
|
||||
header: true,
|
||||
liveRegion: true,
|
||||
child: Text(
|
||||
statusText(l, o.status),
|
||||
key: ValueKey('operation-${o.status.name}'),
|
||||
style: t.textTheme.displaySmall?.copyWith(
|
||||
color: o.status == OperationStatus.failed
|
||||
? c.onErrorContainer
|
||||
: c.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
fundsText(l, o),
|
||||
style: TextStyle(
|
||||
color: o.status == OperationStatus.failed
|
||||
? c.onErrorContainer
|
||||
: c.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
TransferSummary(quote: o.quote),
|
||||
const SizedBox(height: 16),
|
||||
Text('${l.wReference}: ${o.id}'),
|
||||
if (o.status == OperationStatus.pending) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(l.wPendingHint),
|
||||
TextButton(
|
||||
onPressed: p.loading ? null : p.refresh,
|
||||
child: Text(l.wRefresh),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
if (o.status == OperationStatus.completed &&
|
||||
o.quote.request.kind != TransferKind.payout)
|
||||
FilledButton(
|
||||
onPressed: () => context.go(
|
||||
o.quote.request.kind == TransferKind.topUp
|
||||
? '/exchange'
|
||||
: '/payout',
|
||||
extra:
|
||||
o.quote.request.destination ?? o.quote.request.source,
|
||||
),
|
||||
child: Text(
|
||||
o.quote.request.kind == TransferKind.topUp
|
||||
? l.wExchange
|
||||
: l.wPayout,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.go(
|
||||
'/accounts/${o.quote.request.destination ?? o.quote.request.source}',
|
||||
),
|
||||
child: Text(l.wViewAccount),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
|
||||
import '../providers/locale.dart';
|
||||
|
||||
import '../generated/i18n/app_localizations.dart';
|
||||
|
||||
class SettingsPage extends StatelessWidget {
|
||||
const SettingsPage({super.key});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.watch<WalletProvider>();
|
||||
final locale = context.watch<LocaleProvider>();
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final t = Theme.of(context);
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 720),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (context.canPop())
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => context.pop(),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
label: Text(l.kycBack),
|
||||
),
|
||||
),
|
||||
Text(l.wPreferences, style: t.textTheme.displayMedium),
|
||||
const SizedBox(height: 28),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(l.wLanguage, style: t.textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
Text(l.wLanguageHelp),
|
||||
const SizedBox(height: 20),
|
||||
if (p.profileLoading) const LinearProgressIndicator(),
|
||||
if (p.profileFailed) ...[
|
||||
Text(l.wProfileError),
|
||||
TextButton(
|
||||
onPressed: () => locale.loadAccount(p),
|
||||
child: Text(l.wRefresh),
|
||||
),
|
||||
],
|
||||
DropdownButtonFormField<String>(
|
||||
key: ValueKey(
|
||||
'account-language-${locale.locale.languageCode}',
|
||||
),
|
||||
initialValue: locale.locale.languageCode,
|
||||
isExpanded: true,
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'en', child: Text('English')),
|
||||
DropdownMenuItem(value: 'ru', child: Text('Русский')),
|
||||
],
|
||||
onChanged: p.saving || p.profileLoading
|
||||
? null
|
||||
: (value) {
|
||||
if (value != null) {
|
||||
locale.saveAccountLocale(p, Locale(value));
|
||||
}
|
||||
},
|
||||
decoration: InputDecoration(labelText: l.wLanguage),
|
||||
),
|
||||
if (p.saving) const LinearProgressIndicator(),
|
||||
if (p.saveFailed) ...[
|
||||
const SizedBox(height: 12),
|
||||
Semantics(
|
||||
liveRegion: true,
|
||||
child: Text(l.wLanguageFailed),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: p.saving
|
||||
? null
|
||||
: () =>
|
||||
locale.saveAccountLocale(p, locale.locale),
|
||||
child: Text(l.wRefresh),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.all(24),
|
||||
leading: const Icon(Icons.verified_user_outlined),
|
||||
title: Text(l.wIdentity),
|
||||
subtitle: Text(l.wIdentityHelp),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => context.go('/identity'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l.wSecurity, style: t.textTheme.titleLarge),
|
||||
const SizedBox(height: 12),
|
||||
Text(l.wSecurityBody),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
TextButton(
|
||||
onPressed: () => context.go('/'),
|
||||
child: Text(l.wBackLogin),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
|
||||
import '../auth/brand_panel.dart';
|
||||
import '../providers/locale.dart';
|
||||
|
||||
import '../generated/i18n/app_localizations.dart';
|
||||
|
||||
class WorkspaceShell extends StatefulWidget {
|
||||
const WorkspaceShell({super.key, required this.child, required this.path});
|
||||
final Widget child;
|
||||
final String path;
|
||||
@override
|
||||
State<WorkspaceShell> createState() => _WorkspaceShellState();
|
||||
}
|
||||
|
||||
class _WorkspaceShellState extends State<WorkspaceShell> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final wallet = context.read<WalletProvider>();
|
||||
if (wallet.data == null) wallet.refresh();
|
||||
context.read<LocaleProvider>().loadAccount(wallet);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final t = Theme.of(context);
|
||||
final c = t.colorScheme;
|
||||
final paths = ['/accounts', '/activity', '/limits', '/settings'];
|
||||
final labels = [l.wAccounts, l.wActivity, l.wLimits, l.wSettings];
|
||||
final icons = [
|
||||
Icons.account_balance_wallet_outlined,
|
||||
Icons.receipt_long_outlined,
|
||||
Icons.speed_outlined,
|
||||
Icons.tune_rounded,
|
||||
];
|
||||
final index = widget.path.startsWith('/activity')
|
||||
? 1
|
||||
: widget.path.startsWith('/limits') ||
|
||||
widget.path.startsWith('/identity')
|
||||
? 2
|
||||
: widget.path.startsWith('/settings')
|
||||
? 3
|
||||
: 0;
|
||||
void navigate(int i) {
|
||||
if (['/top-up', '/exchange', '/payout'].contains(widget.path) && i >= 2) {
|
||||
context.push(paths[i]);
|
||||
} else {
|
||||
context.go(paths[i]);
|
||||
}
|
||||
}
|
||||
|
||||
final wide = MediaQuery.sizeOf(context).width >= 900;
|
||||
final content = Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: wide
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!wide) ...[const BrandWordmark(), const SizedBox(height: 8)],
|
||||
Text(l.wDemo, style: t.textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(child: widget.child),
|
||||
],
|
||||
);
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Row(
|
||||
children: [
|
||||
if (wide)
|
||||
Container(
|
||||
width: 220,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: c.surfaceContainerLow,
|
||||
border: Border(right: BorderSide(color: c.outlineVariant)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: BrandWordmark(),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
for (var i = 0; i < 4; i++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
selected: i == index,
|
||||
selectedTileColor: c.primaryContainer,
|
||||
leading: Icon(icons[i]),
|
||||
title: Text(labels[i]),
|
||||
onTap: () => navigate(i),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(child: content),
|
||||
],
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: wide
|
||||
? null
|
||||
: NavigationBar(
|
||||
selectedIndex: index,
|
||||
onDestinationSelected: navigate,
|
||||
destinations: [
|
||||
for (var i = 0; i < 4; i++)
|
||||
NavigationDestination(icon: Icon(icons[i]), label: labels[i]),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
|
||||
import 'copy.dart';
|
||||
|
||||
import '../generated/i18n/app_localizations.dart';
|
||||
|
||||
class TransferSummary extends StatelessWidget {
|
||||
const TransferSummary({super.key, required this.quote});
|
||||
final Quote quote;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.read<WalletProvider>();
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final q = quote;
|
||||
final a = p.account(q.request.source);
|
||||
final b = p.account(q.request.destination) ?? a;
|
||||
if (a == null || b == null) return Text(l.wNotFound);
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_row(
|
||||
context,
|
||||
q.request.kind == TransferKind.topUp ? l.wYouPay : l.wTotalDebit,
|
||||
money(context, a, q.debit),
|
||||
large: true,
|
||||
),
|
||||
const Divider(height: 32),
|
||||
_row(context, l.wFee, money(context, a, q.fee)),
|
||||
if (q.request.kind == TransferKind.exchange) ...[
|
||||
const SizedBox(height: 16),
|
||||
_row(
|
||||
context,
|
||||
l.wRate,
|
||||
'1 ${a.currency} = ${NumberFormat('0.0000', Localizations.localeOf(context).toLanguageTag()).format(q.rateNumerator / q.rateDenominator)} ${b.currency}',
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
_row(
|
||||
context,
|
||||
q.request.kind == TransferKind.payout
|
||||
? l.wRecipientGets
|
||||
: l.wCredit,
|
||||
money(context, b, q.credit),
|
||||
large: true,
|
||||
),
|
||||
if (q.request.kind == TransferKind.payout) ...[
|
||||
const Divider(height: 32),
|
||||
Text(l.wRecipient),
|
||||
SelectableText(q.request.recipient),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(
|
||||
BuildContext context,
|
||||
String label,
|
||||
String value, {
|
||||
bool large = false,
|
||||
}) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: large
|
||||
? Theme.of(context).textTheme.headlineSmall
|
||||
: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
|
||||
import 'copy.dart';
|
||||
import 'summary.dart';
|
||||
|
||||
import '../generated/i18n/app_localizations.dart';
|
||||
|
||||
class TransferPage extends StatefulWidget {
|
||||
const TransferPage({super.key, required this.kind, this.source});
|
||||
final TransferKind kind;
|
||||
final String? source;
|
||||
@override
|
||||
State<TransferPage> createState() => TransferPageState();
|
||||
}
|
||||
|
||||
class TransferPageState extends State<TransferPage> {
|
||||
late final _provider = TransferProvider(
|
||||
context.read<WalletProvider>(),
|
||||
widget.kind,
|
||||
source: widget.source,
|
||||
);
|
||||
final _amount = TextEditingController();
|
||||
final _recipient = TextEditingController();
|
||||
final _focus = FocusNode();
|
||||
final _content = GlobalKey();
|
||||
Timer? _timer;
|
||||
bool _navigated = false;
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _provider.addListener(_changed);
|
||||
});
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (mounted && _provider.quote != null) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
void _changed() {
|
||||
if (_provider.result != null && !_navigated) {
|
||||
_navigated = true;
|
||||
context.go('/activity/${_provider.result!.id}');
|
||||
return;
|
||||
}
|
||||
if (!_provider.busy &&
|
||||
(_provider.quote != null || _provider.error != null)) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _content.currentContext != null) {
|
||||
Scrollable.ensureVisible(
|
||||
_content.currentContext!,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
);
|
||||
_focus.requestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> allowExit() async {
|
||||
if (_provider.result != null) return true;
|
||||
if (_provider.busy || _provider.uncertain) return false;
|
||||
if (!_provider.hasDraft) return true;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
return await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(l.wDiscardTitle),
|
||||
content: Text(l.wDiscardBody),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(l.wStay),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(l.wClose),
|
||||
),
|
||||
],
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_provider.removeListener(_changed);
|
||||
_provider.dispose();
|
||||
_amount.dispose();
|
||||
_recipient.dispose();
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ListenableBuilder(
|
||||
listenable: _provider,
|
||||
builder: (context, _) {
|
||||
final p = _provider;
|
||||
final wallet = context.watch<WalletProvider>();
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final t = Theme.of(context);
|
||||
final accounts = wallet.data?.accounts ?? [];
|
||||
final source = wallet.account(p.source);
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 640),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
p.quote == null
|
||||
? kindText(l, widget.kind)
|
||||
: l.wReviewTitle,
|
||||
style: t.textTheme.displaySmall,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l.wClose,
|
||||
onPressed: p.busy || p.uncertain
|
||||
? null
|
||||
: () => context.go('/accounts'),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(l.wDemoMethodHelp, style: t.textTheme.bodySmall),
|
||||
const SizedBox(height: 24),
|
||||
Focus(
|
||||
key: _content,
|
||||
focusNode: _focus,
|
||||
skipTraversal: true,
|
||||
includeSemantics: false,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (p.busy) ...[
|
||||
const LinearProgressIndicator(),
|
||||
const SizedBox(height: 12),
|
||||
Semantics(liveRegion: true, child: Text(l.wWorking)),
|
||||
],
|
||||
if (p.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Semantics(
|
||||
liveRegion: true,
|
||||
child: Text(
|
||||
walletError(l, p.error!),
|
||||
key: const ValueKey('transfer-error'),
|
||||
style: TextStyle(color: t.colorScheme.error),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (p.noSubmission) Text(l.wNoSubmission),
|
||||
if (p.uncertain) ...[
|
||||
Text(l.wUncertain),
|
||||
FilledButton(
|
||||
onPressed: p.busy ? null : p.reconcile,
|
||||
child: Text(l.wCheckOutcome),
|
||||
),
|
||||
] else if (p.quote != null) ...[
|
||||
TransferSummary(quote: p.quote!),
|
||||
const SizedBox(height: 16),
|
||||
Text(p.expired ? l.wExpired : l.wQuoteNotice),
|
||||
const SizedBox(height: 20),
|
||||
if (p.expired)
|
||||
FilledButton(
|
||||
onPressed: p.busy ? null : p.review,
|
||||
child: Text(l.wNewQuote),
|
||||
)
|
||||
else
|
||||
FilledButton(
|
||||
key: const ValueKey('transfer-confirm'),
|
||||
onPressed: p.busy ? null : p.submit,
|
||||
child: Text(l.wConfirm),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: p.busy ? null : p.edit,
|
||||
child: Text(l.wEdit),
|
||||
),
|
||||
] else ...[
|
||||
if (accounts.isEmpty) ...[
|
||||
Text(wallet.loading ? l.wWorking : l.wEmptyAccounts),
|
||||
TextButton(
|
||||
onPressed: wallet.loading ? null : wallet.refresh,
|
||||
child: Text(l.wRefresh),
|
||||
),
|
||||
] else ...[
|
||||
DropdownButtonFormField<String>(
|
||||
key: ValueKey('source-${p.source}'),
|
||||
initialValue: accounts.any((a) => a.id == p.source)
|
||||
? p.source
|
||||
: null,
|
||||
isExpanded: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.kind == TransferKind.topUp
|
||||
? l.wReceiveAccount
|
||||
: l.wSource,
|
||||
),
|
||||
items: accounts
|
||||
.map(
|
||||
(a) => DropdownMenuItem(
|
||||
value: a.id,
|
||||
child: Text(a.currency),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: p.busy
|
||||
? null
|
||||
: (v) => p.change(source: v),
|
||||
),
|
||||
if (source != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'${l.wAvailable}: ${money(context, source, source.available)}',
|
||||
),
|
||||
],
|
||||
if (widget.kind == TransferKind.exchange) ...[
|
||||
const SizedBox(height: 20),
|
||||
DropdownButtonFormField<String>(
|
||||
key: ValueKey('destination-${p.destination}'),
|
||||
initialValue: p.destination,
|
||||
isExpanded: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: l.wDestination,
|
||||
),
|
||||
items: accounts
|
||||
.map(
|
||||
(a) => DropdownMenuItem(
|
||||
value: a.id,
|
||||
child: Text(a.currency),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: p.busy
|
||||
? null
|
||||
: (v) => p.change(destination: v),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
TextField(
|
||||
key: const ValueKey('transfer-amount'),
|
||||
controller: _amount,
|
||||
enabled: !p.busy,
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: l.wAmount,
|
||||
suffixText: source?.currency,
|
||||
),
|
||||
onChanged: (v) => p.change(amount: v),
|
||||
),
|
||||
if (widget.kind == TransferKind.payout) ...[
|
||||
const SizedBox(height: 20),
|
||||
TextField(
|
||||
key: const ValueKey('transfer-recipient'),
|
||||
controller: _recipient,
|
||||
enabled: !p.busy,
|
||||
decoration: InputDecoration(
|
||||
labelText: l.wRecipient,
|
||||
hintText: l.wRecipientHint,
|
||||
),
|
||||
onChanged: (v) => p.change(recipient: v),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 28),
|
||||
FilledButton(
|
||||
key: const ValueKey('transfer-review'),
|
||||
onPressed: p.busy ? null : p.review,
|
||||
child: Text(l.wReview),
|
||||
),
|
||||
if (p.error == WalletError.limitExceeded)
|
||||
TextButton(
|
||||
onPressed: () => context.push('/limits'),
|
||||
child: Text(l.wLimits),
|
||||
),
|
||||
if (p.error == WalletError.insufficientFunds)
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
context.push('/top-up', extra: p.source),
|
||||
child: Text(l.wTopUp),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user