Submit customer identity proofs and show reviewed limits
This commit is contained in:
1 parent
7bf8b8087a
commit
1354551ae8
50 files changed
+1777
-67
No files matched your search
@@ -22,16 +22,17 @@ String reasonText(AppLocalizations l, ReviewReason r) => switch (r) {
|
||||
ReviewReason.passportExpired => l.kycExpiredReason,
|
||||
ReviewReason.unableToVerify => l.kycDeniedReason,
|
||||
};
|
||||
String errorText(AppLocalizations l, KycError e) => switch (e) {
|
||||
KycError.unavailable => l.kycErrorUnavailable,
|
||||
KycError.internal => l.kycErrorInternal,
|
||||
KycError.permission => l.kycErrorPermission,
|
||||
KycError.format => l.kycErrorFormat,
|
||||
KycError.tooLarge => l.kycErrorLarge,
|
||||
KycError.unreadable => l.kycErrorUnreadable,
|
||||
KycError.tooSmall => l.kycErrorSmall,
|
||||
KycError.incomplete => l.kycErrorIncomplete,
|
||||
KycError.sessionExpired => l.kycErrorSession,
|
||||
KycError.rateLimited => l.kycErrorRate,
|
||||
KycError.conflict => l.kycErrorConflict,
|
||||
};
|
||||
String errorText(AppLocalizations l, KycError e, {bool photo = false}) =>
|
||||
switch (e) {
|
||||
KycError.unavailable => l.kycErrorUnavailable,
|
||||
KycError.internal => l.kycErrorInternal,
|
||||
KycError.permission => photo ? l.kycErrorPermission : l.kycErrorAccess,
|
||||
KycError.format => l.kycErrorFormat,
|
||||
KycError.tooLarge => l.kycErrorLarge,
|
||||
KycError.unreadable => l.kycErrorUnreadable,
|
||||
KycError.tooSmall => l.kycErrorSmall,
|
||||
KycError.incomplete => l.kycErrorIncomplete,
|
||||
KycError.sessionExpired => l.kycErrorSession,
|
||||
KycError.rateLimited => l.kycErrorRate,
|
||||
KycError.conflict => l.kycErrorConflict,
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
import 'photo.dart';
|
||||
import 'profile.dart';
|
||||
|
||||
import '../generated/i18n/app_localizations.dart';
|
||||
|
||||
@@ -26,6 +27,10 @@ class IdentityForm extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (p.step == 0) ...[
|
||||
ProfileForm(provider: p),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
LinearProgressIndicator(
|
||||
value: (p.step + 1) / 3,
|
||||
semanticsLabel: titles[p.step],
|
||||
@@ -47,6 +52,10 @@ class IdentityForm extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
if (p.step == 2) ...[
|
||||
Text(
|
||||
'${p.profile.fullName}\n${p.profile.dateOfBirth} · ${p.profile.residenceCountry}',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(l.kycReviewHelp),
|
||||
const SizedBox(height: 16),
|
||||
PhotoPanel(kind: PhotoKind.passport, photo: p.passport, review: true),
|
||||
@@ -68,7 +77,10 @@ class IdentityForm extends StatelessWidget {
|
||||
),
|
||||
FilledButton(
|
||||
key: const ValueKey('kyc-next'),
|
||||
onPressed: p.busy || (p.step == 0 ? p.passport : p.selfie) == null
|
||||
onPressed:
|
||||
p.busy ||
|
||||
(p.step == 0 && !p.profile.isComplete) ||
|
||||
(p.step == 0 ? p.passport : p.selfie) == null
|
||||
? null
|
||||
: p.next,
|
||||
child: Text(l.kycNext),
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
import '../generated/i18n/app_localizations.dart';
|
||||
import 'policy.dart';
|
||||
|
||||
class LimitsPanel extends StatelessWidget {
|
||||
const LimitsPanel({super.key});
|
||||
@@ -48,7 +49,9 @@ class LimitsPanel extends StatelessWidget {
|
||||
style: t.textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (data.rows.isEmpty) Text(l.kycLimitsEmpty),
|
||||
if (data.rows.isEmpty && data.entries.isEmpty)
|
||||
Text(l.kycLimitsEmpty),
|
||||
for (final row in data.entries) PolicyRow(row: row),
|
||||
for (final row in data.rows) ...[
|
||||
const Divider(height: 32),
|
||||
Text(
|
||||
|
||||
@@ -16,7 +16,8 @@ import 'status.dart';
|
||||
import '../generated/i18n/app_localizations.dart';
|
||||
|
||||
class IdentityPage extends StatefulWidget {
|
||||
const IdentityPage({super.key});
|
||||
const IdentityPage({super.key, this.onboarding = false});
|
||||
final bool onboarding;
|
||||
@override
|
||||
State<IdentityPage> createState() => IdentityPageState();
|
||||
}
|
||||
@@ -98,6 +99,10 @@ class IdentityPageState extends State<IdentityPage> {
|
||||
if (!await allowExit() || !mounted) return;
|
||||
_identity.exit();
|
||||
if (account) {
|
||||
if (widget.onboarding) {
|
||||
context.go('/accounts');
|
||||
return;
|
||||
}
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
@@ -166,7 +171,11 @@ class IdentityPageState extends State<IdentityPage> {
|
||||
key: _feedbackKey,
|
||||
liveRegion: true,
|
||||
child: Text(
|
||||
errorText(l, p.error!),
|
||||
errorText(
|
||||
l,
|
||||
p.error!,
|
||||
photo: p.photoError != null,
|
||||
),
|
||||
key: const ValueKey('kyc-error'),
|
||||
style: TextStyle(color: t.colorScheme.error),
|
||||
),
|
||||
@@ -178,6 +187,16 @@ class IdentityPageState extends State<IdentityPage> {
|
||||
l.kycStatusStale,
|
||||
style: TextStyle(color: t.colorScheme.error),
|
||||
),
|
||||
if (p.error == KycError.sessionExpired)
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
if (await allowExit() && mounted) {
|
||||
_identity.exit();
|
||||
if (context.mounted) context.go('/');
|
||||
}
|
||||
},
|
||||
child: Text(l.login),
|
||||
),
|
||||
Focus(
|
||||
includeSemantics: false,
|
||||
key: _contentKey,
|
||||
@@ -198,7 +217,7 @@ class IdentityPageState extends State<IdentityPage> {
|
||||
const LimitsPanel(),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
if (p.editing)
|
||||
if (p.editing && !widget.onboarding)
|
||||
TextButton(
|
||||
onPressed: p.busy ? null : _leave,
|
||||
child: Text(l.kycSkip),
|
||||
@@ -207,7 +226,15 @@ class IdentityPageState extends State<IdentityPage> {
|
||||
onPressed: p.busy
|
||||
? null
|
||||
: () => _leave(account: true),
|
||||
child: Text(l.wLimits),
|
||||
child: Text(
|
||||
widget.onboarding
|
||||
? (p.record?.status ==
|
||||
IdentityStatus.unverified ||
|
||||
p.editing
|
||||
? l.kycSkip
|
||||
: l.kycContinue)
|
||||
: l.wLimits,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
import '../generated/i18n/app_localizations.dart';
|
||||
|
||||
/// Display exact provider decimals; never infer currency scale or active caps.
|
||||
class PolicyRow extends StatelessWidget {
|
||||
const PolicyRow({super.key, required this.row});
|
||||
final PolicyLimit row;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Divider(height: 32),
|
||||
Text(
|
||||
'${row.operation} · ${switch (row.period) {
|
||||
'day' => l.kycDay,
|
||||
'month' => l.kycMonth,
|
||||
'operationMin' => l.kycOperationMinimum,
|
||||
'operationMax' => l.kycOperationMaximum,
|
||||
_ => row.period,
|
||||
}}',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 28,
|
||||
runSpacing: 16,
|
||||
children: [
|
||||
_amount(context, l.kycCurrent, row.cap),
|
||||
if (row.remaining.isNotEmpty)
|
||||
_amount(context, l.kycRemaining, row.remaining),
|
||||
if (row.used.isNotEmpty) _amount(context, l.wUsage, row.used),
|
||||
if (row.reserved.isNotEmpty)
|
||||
_amount(context, l.kycReserved, row.reserved),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _amount(BuildContext context, String label, String amount) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label),
|
||||
Text(
|
||||
'$amount ${row.currency}',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
import '../generated/i18n/app_localizations.dart';
|
||||
|
||||
class ProfileForm extends StatelessWidget {
|
||||
const ProfileForm({super.key, required this.provider});
|
||||
final IdentityProvider provider;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = provider;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
void update({String? name, String? birth, String? country}) =>
|
||||
p.updateProfile(
|
||||
IdentityProfile(
|
||||
fullName: name ?? p.profile.fullName,
|
||||
dateOfBirth: birth ?? p.profile.dateOfBirth,
|
||||
residenceCountry: country ?? p.profile.residenceCountry,
|
||||
),
|
||||
);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(l.kycProfileHelp),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
key: const ValueKey('kyc-name'),
|
||||
initialValue: p.profile.fullName,
|
||||
enabled: !p.busy,
|
||||
maxLength: 200,
|
||||
autofillHints: const [AutofillHints.name],
|
||||
decoration: InputDecoration(labelText: l.kycFullName),
|
||||
onChanged: (value) => update(name: value),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
key: const ValueKey('kyc-birth'),
|
||||
initialValue: p.profile.dateOfBirth,
|
||||
enabled: !p.busy,
|
||||
keyboardType: TextInputType.datetime,
|
||||
autofillHints: const [AutofillHints.birthday],
|
||||
decoration: InputDecoration(
|
||||
labelText: l.kycBirth,
|
||||
helperText: l.kycBirthHelp,
|
||||
),
|
||||
onChanged: (value) => update(birth: value.trim()),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
key: const ValueKey('kyc-country'),
|
||||
initialValue: p.profile.residenceCountry,
|
||||
enabled: !p.busy,
|
||||
maxLength: 2,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
decoration: InputDecoration(
|
||||
labelText: l.kycCountry,
|
||||
helperText: l.kycCountryHelp,
|
||||
),
|
||||
onChanged: (value) => update(country: value.trim().toUpperCase()),
|
||||
),
|
||||
if (!p.profile.isComplete) Text(l.kycProfileRequired),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,9 @@ class StatusPanel extends StatelessWidget {
|
||||
header: true,
|
||||
liveRegion: true,
|
||||
child: Text(
|
||||
statusTitle(l, status),
|
||||
record.upgradePending
|
||||
? l.kycUpgradePendingTitle
|
||||
: statusTitle(l, status),
|
||||
key: ValueKey('kyc-${status.name}'),
|
||||
style: t.textTheme.headlineMedium?.copyWith(
|
||||
color: t.colorScheme.onPrimaryContainer,
|
||||
@@ -53,7 +55,9 @@ class StatusPanel extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
statusBody(l, status),
|
||||
record.upgradePending
|
||||
? l.kycUpgradePendingBody
|
||||
: statusBody(l, status),
|
||||
style: TextStyle(color: t.colorScheme.onPrimaryContainer),
|
||||
),
|
||||
if (record.reason != null) ...[
|
||||
|
||||
Reference in new issue
Block a user