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

+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),
),
],
),
),
),
),
),
),
);
},
),
);
}