Add optional KYC flow and service-backed account limits

This commit is contained in:
Stephan D committed 2026-09-12 09:28:24 +02:00
1 parent 61ccfc1844
commit 3397f04ab7
38 files changed
+3443 -36

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import '../generated/i18n/app_localizations.dart';
String statusTitle(AppLocalizations l, IdentityStatus s) => switch (s) {
IdentityStatus.unverified => l.kycUnverified,
IdentityStatus.pending => l.kycPending,
IdentityStatus.approved => l.kycApproved,
IdentityStatus.changesRequested => l.kycChanges,
IdentityStatus.denied => l.kycDenied,
};
String statusBody(AppLocalizations l, IdentityStatus s) => switch (s) {
IdentityStatus.unverified => l.kycUnverifiedBody,
IdentityStatus.pending => l.kycPendingBody,
IdentityStatus.approved => l.kycApprovedBody,
IdentityStatus.changesRequested => l.kycChangesBody,
IdentityStatus.denied => l.kycDeniedBody,
};
String reasonText(AppLocalizations l, ReviewReason r) => switch (r) {
ReviewReason.passportUnreadable => l.kycPassportReason,
ReviewReason.selfieUnreadable => l.kycSelfieReason,
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,
};
+87
View File
@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'photo.dart';
import '../generated/i18n/app_localizations.dart';
class IdentityForm extends StatelessWidget {
const IdentityForm({super.key, required this.provider});
final IdentityProvider provider;
@override
Widget build(BuildContext context) {
final p = provider;
final l = AppLocalizations.of(context)!;
final titles = [l.kycPassport, l.kycSelfie, l.kycReview];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Semantics(
header: true,
liveRegion: true,
child: Text(
'${p.step + 1} / 3 · ${titles[p.step]}',
style: Theme.of(context).textTheme.headlineSmall,
),
),
const SizedBox(height: 16),
LinearProgressIndicator(
value: (p.step + 1) / 3,
semanticsLabel: titles[p.step],
),
const SizedBox(height: 16),
if (p.step < 2)
PhotoPanel(
kind: p.step == 0 ? PhotoKind.passport : PhotoKind.selfie,
photo: p.step == 0 ? p.passport : p.selfie,
onPick: p.busy
? null
: () => p.pick(
p.step == 0 ? PhotoKind.passport : PhotoKind.selfie,
),
onRemove: p.busy
? null
: () => p.remove(
p.step == 0 ? PhotoKind.passport : PhotoKind.selfie,
),
),
if (p.step == 2) ...[
Text(l.kycReviewHelp),
const SizedBox(height: 16),
PhotoPanel(kind: PhotoKind.passport, photo: p.passport, review: true),
PhotoPanel(kind: PhotoKind.selfie, photo: p.selfie, review: true),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
value: p.confirmed,
onChanged: p.busy ? null : (v) => p.confirm(v ?? false),
title: Text(l.kycConfirm),
),
],
const SizedBox(height: 16),
if (p.step < 2) ...[
if ((p.step == 0 ? p.passport : p.selfie) == null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(l.kycChooseFirst),
),
FilledButton(
key: const ValueKey('kyc-next'),
onPressed: p.busy || (p.step == 0 ? p.passport : p.selfie) == null
? null
: p.next,
child: Text(l.kycNext),
),
] else
FilledButton(
key: const ValueKey('kyc-submit'),
onPressed: p.canSubmit ? p.submit : null,
child: Text(p.isDemo ? l.kycSubmitDemo : l.kycSubmit),
),
if (p.step > 0)
TextButton(onPressed: p.busy ? null : p.back, child: Text(l.kycBack)),
],
);
}
}
+111
View File
@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import '../generated/i18n/app_localizations.dart';
class LimitsPanel extends StatelessWidget {
const LimitsPanel({super.key});
@override
Widget build(BuildContext context) {
final p = context.watch<LimitsProvider>();
final l = AppLocalizations.of(context)!;
final t = Theme.of(context);
final data = p.data;
return Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l.kycLimits, style: t.textTheme.headlineSmall),
if (p.loading) ...[
const SizedBox(height: 16),
const LinearProgressIndicator(),
],
if (p.failed) ...[
const SizedBox(height: 12),
Semantics(
liveRegion: true,
child: Text(
l.kycLimitsError,
style: TextStyle(color: t.colorScheme.error),
),
),
TextButton(
onPressed: p.loading ? null : p.refresh,
child: Text(l.kycRefresh),
),
],
if (data != null) ...[
const SizedBox(height: 12),
Text(
data.tier == AccountTier.basic ? l.kycBasic : l.kycVerifiedTier,
style: t.textTheme.titleLarge,
),
if (data.simulated)
Text(l.kycLimitsDemo, style: t.textTheme.bodySmall),
if (data.rows.isEmpty) Text(l.kycLimitsEmpty),
for (final row in data.rows) ...[
const Divider(height: 32),
Text(
row.period == LimitPeriod.day ? l.kycDay : l.kycMonth,
style: t.textTheme.titleMedium,
),
const SizedBox(height: 12),
Wrap(
spacing: 28,
runSpacing: 16,
children: [
_amount(
context,
l.kycCurrent,
row.currentMinor,
row.currency,
),
_amount(
context,
l.kycRemaining,
row.remainingMinor,
row.currency,
),
if (data.tier == AccountTier.basic)
_amount(
context,
l.kycAfter,
row.verifiedMinor,
row.currency,
),
],
),
],
],
],
),
),
);
}
Widget _amount(
BuildContext context,
String label,
int minor,
String currency,
) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label),
Text(
NumberFormat.simpleCurrency(
locale: Localizations.localeOf(context).toLanguageTag(),
name: currency,
).format(minor / 100),
style: Theme.of(context).textTheme.titleLarge,
),
],
);
}
+240
View File
@@ -0,0 +1,240 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import '../auth/brand_panel.dart';
import '../routing/routes.dart';
import '../widgets/locale.dart';
import 'copy.dart';
import 'form.dart';
import 'limits.dart';
import 'status.dart';
import '../generated/i18n/app_localizations.dart';
class IdentityPage extends StatefulWidget {
const IdentityPage({super.key});
@override
State<IdentityPage> createState() => IdentityPageState();
}
class IdentityPageState extends State<IdentityPage> {
late final _limits = LimitsProvider(context.read<LimitsService>());
late final _identity = IdentityProvider(
context.read<IdentityService>(),
context.read<PhotoService>(),
JourneyRecorder(context.read<AnalyticsService>()),
onStatusChanged: _limits.refresh,
);
final _contentKey = GlobalKey();
final _feedbackKey = GlobalKey();
final _focus = FocusNode(skipTraversal: true);
String? _lastView;
void _transition() {
final p = _identity;
if (p.busy) return;
final view = '${p.editing}:${p.step}:${p.record?.status}:${p.error}';
if (_lastView == null) {
_lastView = view;
return;
}
if (view == _lastView) return;
_lastView = view;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final target = p.error != null
? _feedbackKey.currentContext
: _contentKey.currentContext;
if (target != null) {
Scrollable.ensureVisible(
target,
alignment: 0.05,
duration: const Duration(milliseconds: 180),
);
}
if (p.error == null) _focus.requestFocus();
});
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_identity.addListener(_transition);
_identity.refresh();
}
});
}
Future<bool> allowExit() async {
if (_identity.busy) return false;
if (!_identity.hasDraft) return true;
final l = AppLocalizations.of(context)!;
return await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l.kycDiscardTitle),
content: Text(l.kycDiscardBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(l.kycStay),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(l.kycDiscard),
),
],
),
) ??
false;
}
Future<void> _leave({bool account = false}) async {
if (!await allowExit() || !mounted) return;
_identity.exit();
if (account) context.goNamed(AppRoutes.login);
}
@override
void dispose() {
_identity.removeListener(_transition);
_identity.dispose();
_focus.dispose();
_limits.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => ChangeNotifierProvider.value(
value: _limits,
child: ListenableBuilder(
listenable: _identity,
builder: (context, _) {
final p = _identity;
final l = AppLocalizations.of(context)!;
final t = Theme.of(context);
return PopScope(
canPop: !p.hasDraft && !p.busy,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _leave(account: true);
},
child: Scaffold(
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Wrap(
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 16,
children: [BrandWordmark(), LocaleSelector()],
),
const SizedBox(height: 36),
Semantics(
header: true,
child: Text(
l.kycTitle,
style: t.textTheme.displaySmall,
),
),
const SizedBox(height: 12),
Text(l.kycIntro, style: t.textTheme.bodyLarge),
const SizedBox(height: 20),
if (p.isDemo)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: t.colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Text(
l.kycDemo,
style: TextStyle(
color: t.colorScheme.onTertiaryContainer,
),
),
),
const SizedBox(height: 24),
if (p.busy) ...[
const LinearProgressIndicator(),
const SizedBox(height: 12),
Semantics(
liveRegion: true,
child: Text(
p.record == null ? l.kycLoading : l.kycWorking,
),
),
const SizedBox(height: 16),
],
if (p.error != null) ...[
Semantics(
key: _feedbackKey,
liveRegion: true,
child: Text(
errorText(l, p.error!),
key: const ValueKey('kyc-error'),
style: TextStyle(color: t.colorScheme.error),
),
),
const SizedBox(height: 16),
],
if (p.stale && p.record != null)
Text(
l.kycStatusStale,
style: TextStyle(color: t.colorScheme.error),
),
Focus(
key: _contentKey,
focusNode: _focus,
child: p.editing
? IdentityForm(provider: p)
: p.record != null
? StatusPanel(provider: p)
: const SizedBox.shrink(),
),
if (!p.editing || p.stale)
TextButton(
onPressed: p.busy ? null : p.refresh,
child: Text(l.kycRefresh),
),
if (!p.editing) ...[
const SizedBox(height: 16),
const LimitsPanel(),
],
const SizedBox(height: 16),
if (p.editing)
TextButton(
onPressed: p.busy ? null : _leave,
child: Text(l.kycSkip),
),
TextButton(
onPressed: p.busy
? null
: () => _leave(account: true),
child: Text(l.kycClose),
),
],
),
),
),
),
),
),
);
},
),
);
}
+78
View File
@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'preview.dart';
import '../generated/i18n/app_localizations.dart';
class PhotoPanel extends StatelessWidget {
const PhotoPanel({
super.key,
required this.kind,
required this.photo,
this.onPick,
this.onRemove,
this.review = false,
});
final PhotoKind kind;
final Photo? photo;
final VoidCallback? onPick;
final VoidCallback? onRemove;
final bool review;
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final t = Theme.of(context);
final passport = kind == PhotoKind.passport;
final title = passport ? l.kycPassport : l.kycSelfie;
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(title, style: t.textTheme.titleLarge),
const SizedBox(height: 16),
if (photo != null)
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: PhotoPreview(photo: photo!, label: title),
)
else
Container(
height: 140,
decoration: BoxDecoration(
color: t.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
),
child: Icon(
passport ? Icons.badge_outlined : Icons.portrait_outlined,
size: 64,
color: t.colorScheme.primary,
),
),
if (!review) ...[
const SizedBox(height: 16),
Text(passport ? l.kycPassportHelp : l.kycSelfieHelp),
const SizedBox(height: 12),
Text(l.kycFileRules, style: t.textTheme.bodySmall),
const SizedBox(height: 16),
OutlinedButton.icon(
key: ValueKey('pick-${kind.name}'),
onPressed: onPick,
icon: const Icon(Icons.add_photo_alternate_outlined),
label: Text(
photo == null ? l.kycChoose : l.kycReplace,
textAlign: TextAlign.center,
),
),
if (photo != null)
TextButton(onPressed: onRemove, child: Text(l.kycRemove)),
],
],
),
),
);
}
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import '../generated/i18n/app_localizations.dart';
/// Evict this sensitive preview's decoded cache entry on replacement or exit.
class PhotoPreview extends StatefulWidget {
const PhotoPreview({super.key, required this.photo, required this.label});
final Photo photo;
final String label;
@override
State<PhotoPreview> createState() => _PhotoPreviewState();
}
class _PhotoPreviewState extends State<PhotoPreview> {
late ImageProvider _image = _provider();
ImageProvider _provider() =>
ResizeImage.resizeIfNeeded(1000, null, MemoryImage(widget.photo.bytes));
@override
void didUpdateWidget(covariant PhotoPreview oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.photo, widget.photo)) {
_image.evict();
_image = _provider();
}
}
@override
void dispose() {
_image.evict();
super.dispose();
}
@override
Widget build(BuildContext context) => Image(
image: _image,
height: 220,
fit: BoxFit.contain,
semanticLabel: widget.label,
errorBuilder: (_, _, _) =>
Text(AppLocalizations.of(context)!.kycErrorUnreadable),
);
}
+158
View File
@@ -0,0 +1,158 @@
import 'package:flutter/material.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'copy.dart';
import '../generated/i18n/app_localizations.dart';
class StatusPanel extends StatelessWidget {
const StatusPanel({super.key, required this.provider});
final IdentityProvider provider;
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final t = Theme.of(context);
final p = provider;
final record = p.record!;
final status = record.status;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: t.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(24),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
switch (status) {
IdentityStatus.approved => Icons.verified_outlined,
IdentityStatus.pending => Icons.hourglass_top,
IdentityStatus.changesRequested ||
IdentityStatus.denied => Icons.info_outline,
_ => Icons.shield_outlined,
},
color: t.colorScheme.onPrimaryContainer,
size: 36,
),
const SizedBox(height: 16),
Semantics(
header: true,
liveRegion: true,
child: Text(
statusTitle(l, status),
key: ValueKey('kyc-${status.name}'),
style: t.textTheme.headlineMedium?.copyWith(
color: t.colorScheme.onPrimaryContainer,
),
),
),
const SizedBox(height: 12),
Text(
statusBody(l, status),
style: TextStyle(color: t.colorScheme.onPrimaryContainer),
),
if (record.reason != null) ...[
const SizedBox(height: 12),
Text(
reasonText(l, record.reason!),
style: TextStyle(
color: t.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.bold,
),
),
],
],
),
),
const SizedBox(height: 20),
if (record.canSubmit)
FilledButton(
key: const ValueKey('kyc-start'),
onPressed: p.busy || p.stale ? null : p.start,
child: Text(
status == IdentityStatus.changesRequested
? l.kycResubmit
: l.kycStart,
),
),
if (status == IdentityStatus.denied)
OutlinedButton(
onPressed: () => showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: Text(l.kycSupport),
content: Text(l.kycSupportBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l.kycBack),
),
],
),
),
child: Text(l.kycSupport),
),
if (status == IdentityStatus.pending &&
p.service is DummyIdentityService)
ExpansionTile(
title: Text(l.kycDemoControls),
children: [
Wrap(
spacing: 8,
runSpacing: 8,
children: [
TextButton(
onPressed: p.busy
? null
: () => p.demoReview(IdentityStatus.approved),
child: Text(l.kycDemoApprove),
),
TextButton(
onPressed: p.busy
? null
: () => p.demoReview(
IdentityStatus.changesRequested,
reason: ReviewReason.passportUnreadable,
),
child: Text(l.kycDemoCorrect),
),
TextButton(
onPressed: p.busy
? null
: () => p.demoReview(
IdentityStatus.changesRequested,
reason: ReviewReason.selfieUnreadable,
),
child: Text(l.kycDemoSelfie),
),
TextButton(
onPressed: p.busy
? null
: () => p.demoReview(
IdentityStatus.changesRequested,
reason: ReviewReason.passportExpired,
),
child: Text(l.kycDemoExpired),
),
TextButton(
onPressed: p.busy
? null
: () => p.demoReview(
IdentityStatus.denied,
reason: ReviewReason.unableToVerify,
),
child: Text(l.kycDemoDeny),
),
],
),
],
),
],
);
}
}