← Code knowledge base
Reviewed Flutter implementation · 2026-10-28

kyc_screen.dart

mobile_flutter/lib/features/kyc/presentation/kyc_screen.dart

Immutable source snapshot based on revision ce45c9c3bbde. Original line numbers are retained. This is implementation evidence; check the public API catalogue before adapting it.

The mobile /api/v1/mobile routes belong to the example backend. Company credentials stay on that backend. Some sections outside the documented product scope may be omitted.

Lines 1–1748

import 'unified_cdd_screen.dart';import 'package:mobile_flutter/core/l10n/app_localizations.dart';import 'dart:async'; import 'package:flutter/material.dart';import 'package:flutter_riverpod/flutter_riverpod.dart';import 'package:go_router/go_router.dart';import 'package:url_launcher/url_launcher.dart'; import '../../../app/shell/banking_shell.dart';import '../../../brands/cardeo/cardeo.dart';import '../../../core/widgets/app_states.dart';import '../application/kyc_providers.dart';import '../data/kyc_api.dart';import 'kyc_resubmission_panel.dart';import '../../platform/application/platform_providers.dart';import '../../profile/presentation/kyc_status_overview_screen.dart';import '../../../shared/widgets/app_progress_indicator.dart';import '../../../shared/widgets/searchable_multi_select_dropdown.dart'; class KycScreen extends ConsumerStatefulWidget {  const KycScreen({super.key});   @override  ConsumerState<KycScreen> createState() => _KycScreenState();} class _KycScreenState extends ConsumerState<KycScreen> {  static const _salaryOptions = [    '0-10000',    '10001-30000',    '30001-50000',    '50001-75000',    '75001-100000',    '100001-150000',    '150001-250000',    '250001+',  ];  static const _volumeOptions = [    '0-1000',    '1001-5000',    '5001-15000',    '15001-50000',    '50001-100000',    '100001+',  ];   /// Form measure on Cardeo: a column of inputs stops being readable past  /// about 520 px, so the desktop layout centres it instead of stretching.  static const double _formMeasure = 520;   final _formKey = GlobalKey<FormState>();  final _purposeController = TextEditingController();  final _documentIssueDateController = TextEditingController();  String? _occupation;  String? _salary = _salaryOptions[3];  String? _volume = _volumeOptions[1];  bool _wasResubmission = false;   @override  void initState() {    super.initState();    WidgetsBinding.instance.addPostFrameCallback((_) {      if (mounted) ref.invalidate(kycDetailedStatusProvider);    });  }   @override  void dispose() {    _purposeController.dispose();    _documentIssueDateController.dispose();    super.dispose();  }   Future<void> _showHostedVerificationDialog({required bool blocked}) async {    final link = ref.read(hostedKycLinkProvider);    if (link == null || !mounted) return;    Future<void> open() async {      await launchUrl(        link,        mode: LaunchMode.externalApplication,        webOnlyWindowName: '_blank',      );    }     if (context.isCardeoTheme) {      await showDialog<void>(        context: context,        builder: (dialogContext) => _KycHandoffDialog(          blocked: blocked,          onOpen: open,          onCheckStatus: () {            Navigator.of(dialogContext).pop();            context.go('/kyc/status');          },        ),      );      return;    }    await showDialog<void>(      context: context,      builder: (dialogContext) => AlertDialog(        title: Text(blocked            ? context.tr('Open verification')            : context.tr('Verification opened')),        content: Text(          blocked              ? context.tr(                  'Your browser blocked the verification tab. Use the button below to open it.')              : context.tr(                  'Identity verification opened in a new tab. Complete it there, then come back and check your status.'),        ),        actions: [          TextButton(            onPressed: open,            child: Text(blocked                ? context.tr('Open verification')                : context.tr('Open again')),          ),          FilledButton(            onPressed: () {              Navigator.of(dialogContext).pop();              context.go('/kyc/status');            },            child: Text(context.tr('Check status')),          ),        ],      ),    );  }   @override  Widget build(BuildContext context) {    final unified = ref.watch(unifiedCddPreferenceProvider);    if (unified.isLoading) return Scaffold(body: LoadingState(label: context.tr('Loading onboarding')));    if (unified.hasError) return Scaffold(body: ErrorState(error: unified.error!, onRetry: () => ref.invalidate(unifiedCddPreferenceProvider)));    if (unified.valueOrNull == true) return const UnifiedCddScreen();    final detailedStatus = ref.watch(kycDetailedStatusProvider);    final resetStatus = detailedStatus.valueOrNull;    if (resetStatus == null) {      return Scaffold(        appBar: AppBar(title: Text(context.tr('Identity verification'))),        body: detailedStatus.hasError            ? ErrorState(                error: detailedStatus.error!,                onRetry: () => ref.invalidate(kycDetailedStatusProvider))            : LoadingState(label: context.tr('Loading KYC status')),      );    }    if (resetStatus.hasInterlaceAction) {      _wasResubmission = true;      return Scaffold(        appBar: AppBar(title: Text(context.tr('Identity verification'))),        body: SafeArea(            child: ListView(                padding: const EdgeInsets.all(16),                children: [KycResubmissionPanel(status: resetStatus)])),      );    }    // Returning from hosted verification can recreate this screen, so use the    // persisted identity approval as well as this widget's resubmission state.    if (_wasResubmission || resetStatus.isHoppaApproved) {      return const HoppaKycStatusScreen();    }    final state = ref.watch(kycControllerProvider);    final occupations = ref.watch(occupationCodesProvider);    final isCardeo = context.isCardeoTheme;     ref.listen(kycControllerProvider, (previous, next) {      next.whenOrNull(        data: (outcome) {          if (outcome == KycLaunchOutcome.openedInNewTab ||              outcome == KycLaunchOutcome.popupBlocked) {            if ((previous?.isLoading ?? false)) {              _showHostedVerificationDialog(                blocked: outcome == KycLaunchOutcome.popupBlocked,              );            }            return;          }          if ((previous?.isLoading ?? false) && (previous?.hasValue ?? false)) {            ScaffoldMessenger.of(context).showSnackBar(              SnackBar(content: Text(context.tr('Verification submitted'))),            );            context.go('/kyc/status');          }        },        error: (error, stackTrace) {          ScaffoldMessenger.of(            context,          ).showSnackBar(SnackBar(content: Text(friendlyErrorMessage(error))));        },      );    });     return Scaffold(      appBar: AppBar(title: Text(context.tr('Identity verification'))),      body: SafeArea(        child: Form(          key: _formKey,          child: isCardeo              ? _cardeoBody(context, state: state, occupations: occupations)              : ListView(                  padding: const EdgeInsets.all(24),                  children: [                    Text(                      context.tr('Verify your identity'),                      style: Theme.of(context).textTheme.headlineMedium,                    ),                    const SizedBox(height: 12),                    Text(                      context.tr(                          'Answer a few questions before starting secure identity checks.'),                      style: Theme.of(context).textTheme.bodyLarge,                    ),                    const SizedBox(height: 24),                    _occupationField(occupations),                    const SizedBox(height: 12),                    _salaryField(),                    const SizedBox(height: 12),                    _purposeField(),                    const SizedBox(height: 12),                    _volumeField(),                    const SizedBox(height: 12),                    _issueDateField(),                    const SizedBox(height: 24),                    _submitButton(state, cardeo: false),                  ],                ),        ),      ),    );  }   /// The Cardeo verification screen in both themes: the journey as a vertical  /// progress with a hairline connector, the launch state as one pill and one  /// glyph, and the questionnaire under a section title.  ///  /// A utility screen, so there is no arrival moment. The only motion is the  /// 200 ms status swap and the soft sheen that crosses the step panel on the  /// shell's cadence.  Widget _cardeoBody(    BuildContext context, {    required AsyncValue<KycLaunchOutcome?> state,    required AsyncValue<List<OccupationCode>> occupations,  }) {    final theme = Theme.of(context);    final desktop =        MediaQuery.sizeOf(context).width >= CardeoBreakpoints.desktop;    final status = _KycStatus.from(state);    final gutter = desktop ? 40.0 : AppSpacing.md;     Widget content = ListView(      padding: EdgeInsets.fromLTRB(        gutter,        AppSpacing.md,        gutter,        AppSpacing.xxl,      ),      children: [        Center(          child: ConstrainedBox(            constraints: const BoxConstraints(maxWidth: _formMeasure),            child: Column(              crossAxisAlignment: CrossAxisAlignment.stretch,              children: [                Text(                  context.tr('Verify your identity'),                  style: theme.textTheme.headlineSmall,                ),                const SizedBox(height: AppSpacing.xs),                Text(                  context.tr(                      'Answer a few questions before starting secure identity checks.'),                  style: theme.textTheme.bodyMedium?.copyWith(                    color: CardeoInk.secondary(context),                    height: 1.4,                  ),                ),                const SizedBox(height: AppSpacing.lg),                _KycProgressPanel(status: status),                const SizedBox(height: AppSpacing.md),                // What the secure window will ask for, stated before it                // opens. The single loudest complaint about hosted KYC is                // that a customer is handed to a third party without being                // told what they are about to be asked to photograph.                _KycDocumentChecklist(status: status),                if (state.hasError) ...[                  const SizedBox(height: AppSpacing.md),                  _KycFailureNotice(error: state.error!),                ],                const SizedBox(height: AppSpacing.lg),                CardeoSectionTitle(title: context.tr('About you')),                const SizedBox(height: AppSpacing.xxs),                Text(                  context.tr(                      'Five questions. Every one of them is asked because a regulator requires it, and each says what it is used for.'),                  style: TextStyle(                    fontSize: 12.5,                    height: 1.4,                    // Secondary ink: 7.3:1 or better on the page in both                    // themes, so this is a sentence at body grade rather                    // than a caption the customer has to squint at.                    color: CardeoInk.secondary(context),                  ),                ),                const SizedBox(height: AppSpacing.md),                _occupationField(occupations),                const SizedBox(height: AppSpacing.md),                _salaryField(),                const SizedBox(height: AppSpacing.md),                _purposeField(),                const SizedBox(height: AppSpacing.md),                _volumeField(),                const SizedBox(height: AppSpacing.md),                _issueDateField(),                const SizedBox(height: AppSpacing.lg),                _submitButton(state, cardeo: true),                const SizedBox(height: AppSpacing.md),                // Reassurance sits *after* the CTA on purpose: it is the                // last thing read before a passport is handed over, and it                // is a footnote rather than a card because reassurance that                // shouts stops being reassurance.                const _KycAssurancePanel(),              ],            ),          ),        ),      ],    );     // The shell already provides the alive layer; a screen opened outside it    // gets its own so the step panel still has a clock.    if (!CardeoSheenScope.existsAbove(context)) {      content = CardeoSheenScope(child: content);    }    return content;  }   Widget _occupationField(AsyncValue<List<OccupationCode>> occupations) {    return occupations.when(      data: (items) {        if (items.isEmpty) {          return _OptionsError(            message:                context.tr('Occupation options are temporarily unavailable.'),            onRetry: () => ref.invalidate(occupationCodesProvider),          );        }         final labels = {          for (final occupation in items)            occupation.value: context.tr(occupation.title),        };        return _field(          label: context.tr('Occupation'),          why: 'The regulator that licenses your account requires it.',          child: SearchableSingleSelectDropdown(            label: context.tr('Occupation'),            showLabel: false,            value: _occupation,            options: labels.keys.toList(),            labelBuilder: (value) => labels[value] ?? value,            onChanged: (value) => setState(() => _occupation = value),          ),        );      },      error: (error, stackTrace) => _OptionsError(        message: friendlyErrorMessage(error),        onRetry: () => ref.invalidate(occupationCodesProvider),      ),      loading: () => _LoadingField(label: context.tr('Occupation')),    );  }   Widget _salaryField() => _field(        label: context.tr('Annual salary'),        why: 'A range is enough. We never ask for an exact figure.',        child: DropdownButtonFormField<String>(          initialValue: _salary,          // Cardeo lets the selected range flex and pushes the caret to the          // field's own right edge. Measured: at 2x type on a 375 window the          // unexpanded row runs 8 px past the control, because a money range          // is the longest string this form ever puts in a closed dropdown.          // Other brands keep today's hugging layout, and today's bug.          isExpanded: context.isCardeoTheme,          decoration: _decoration(            'Annual salary',            cardeoHint: 'Select a range',          ),          items: [            for (final option in _salaryOptions)              DropdownMenuItem(                value: option,                child: Text(                  _moneyRangeLabel(option),                  maxLines: 1,                  overflow: TextOverflow.ellipsis,                ),              ),          ],          validator: (value) =>              value == null ? 'Annual salary is required' : null,          onChanged: (value) => setState(() => _salary = value),        ),      );   Widget _purposeField() => _field(        label: context.tr('Account purpose'),        why: 'Tells us what normal activity looks like on your account.',        child: _RequiredTextField(          controller: _purposeController,          label: context.tr('Account purpose'),          hint: 'Everyday banking',        ),      );   Widget _volumeField() => _field(        label: context.tr('Expected monthly volume'),        why: 'Sets your starting limits. An estimate is fine.',        child: DropdownButtonFormField<String>(          initialValue: _volume,          // Same measured overflow as the salary field, same fix.          isExpanded: context.isCardeoTheme,          decoration: _decoration(            'Expected monthly volume',            cardeoHint: 'Select a range',          ),          items: [            for (final option in _volumeOptions)              DropdownMenuItem(                value: option,                child: Text(                  _moneyRangeLabel(option),                  maxLines: 1,                  overflow: TextOverflow.ellipsis,                ),              ),          ],          validator: (value) =>              value == null ? 'Expected monthly volume is required' : null,          onChanged: (value) => setState(() => _volume = value),        ),      );   Widget _issueDateField() => _field(        label: context.tr('Document issue date'),        why: 'Must match the ID you photograph in the next step.',        child: Column(          crossAxisAlignment: CrossAxisAlignment.start,          children: [            TextFormField(              controller: _documentIssueDateController,              readOnly: true,              decoration: _decoration(                'Document issue date',                hint: 'YYYY-MM-DD',                suffixIcon: const Icon(Icons.calendar_month_outlined),              ),              validator: (value) {                if (value == null || value.trim().isEmpty) {                  return context.tr('Document issue date is required');                }                 return null;              },              onTap: _pickDocumentIssueDate,            ),            const SizedBox(height: 8),            Text(              context.tr(                "If no issue date is shown, subtract 5 or 10 years from the expiry date, depending on your document's validity period.",              ),              style: TextStyle(                fontSize: 12,                height: 1.4,                color: context.isCardeoTheme                    ? CardeoInk.secondary(context)                    : Theme.of(context).colorScheme.onSurfaceVariant,              ),            ),          ],        ),      );   /// Submit the original questionnaire after validating every field.  void _startKyc() {    if (!_formKey.currentState!.validate()) {      return;    }     ref.read(kycControllerProvider.notifier).startKyc({      'Occupation': _occupation,      'AnnualSalary': _salary,      'AccountPurpose': _purposeController.text.trim(),      'ExpectedMonthlyVolume': _volume,      'DocumentIssueDate': _documentIssueDateController.text.trim(),    });  }   Widget _submitButton(    AsyncValue<KycLaunchOutcome?> state, {    required bool cardeo,  }) {    if (cardeo) {      // The one decisive action on the screen, so it gets the glass      // material. Ground is `surface`: this CTA scrolls with the form on a      // painted page rather than sitting pinned over the atmosphere, and a      // backdrop blur that samples a flat panel is a blur that dissolves.      // No arrival sheen — verification is a utility register, and a CTA      // that sparkles at someone about to photograph a passport is the      // wrong tone entirely.      return CardeoGlassButton(        label: context.tr('Start verification'),        icon: Icons.lock_outline_rounded,        loading: state.isLoading,        loadingSemanticsLabel: 'Starting verification',        onPressed: state.isLoading ? null : _startKyc,      );    }    return FilledButton(      onPressed: state.isLoading ? null : _startKyc,      child: state.isLoading          ? const SizedBox.square(              dimension: 18,              child: AppProgressIndicator(strokeWidth: 2),            )          : Text(context.tr('Start verification')),    );  }   /// Wraps one question in the Cardeo field composition; every other brand  /// gets the stock Material field back, unchanged.  Widget _field({    required String label,    required String why,    required Widget child,  }) =>      context.isCardeoTheme          ? _KycField(label: label, why: why, child: child)          : child;   /// Decoration for one question. Cardeo drops the floating label — the  /// question is already stated above the control at full size, and a label  /// that shrinks to 12 px the instant the field has a value is the wrong  /// behaviour for a form where every question needs justifying. Every other  /// brand keeps today's decoration exactly.  InputDecoration _decoration(    String label, {    String? hint,    String? cardeoHint,    Widget? suffixIcon,  }) =>      context.isCardeoTheme          ? InputDecoration(              hintText: cardeoHint ?? hint,              suffixIcon: suffixIcon,            )          : InputDecoration(              labelText: label,              hintText: hint,              suffixIcon: suffixIcon,            );   Future<void> _pickDocumentIssueDate() async {    final now = DateTime.now();    final selected = await showDatePicker(      context: context,      initialDate: DateTime(now.year - 1, now.month, now.day),      firstDate: DateTime(1950),      lastDate: now,    );    if (selected == null) {      return;    }     _documentIssueDateController.text =        '${selected.year.toString().padLeft(4, '0')}-'        '${selected.month.toString().padLeft(2, '0')}-'        '${selected.day.toString().padLeft(2, '0')}';  }} String _moneyRangeLabel(String value) {  final parts = value.split('-');  if (parts.length == 2) {    return 'EUR ${parts[0]} to ${parts[1]}';  }  if (value.endsWith('+')) {    return 'EUR ${value.substring(0, value.length - 1)}+';  }   return value;} /// Where the customer is in the journey, and what the launch attempt is/// currently saying about it. Pending, active and in review are the registers/// this screen can reach; the verified register belongs to the status screen/// after the partner answers. The semantic hue lands on the pill and the/// glyph only, never on the panel behind them.enum _KycTone { pending, active, review, attention } class _KycStatus {  const _KycStatus({    required this.tone,    required this.step,    required this.label,    required this.line,    required this.icon,  });   factory _KycStatus.from(AsyncValue<KycLaunchOutcome?> state) {    if (state.isLoading) {      return const _KycStatus(        tone: _KycTone.pending,        step: 0,        label: 'Starting',        line: 'Opening the secure verification window.',        icon: Icons.autorenew_rounded,      );    }    if (state.hasError) {      return const _KycStatus(        tone: _KycTone.attention,        step: 0,        label: 'Not started',        line: 'Verification could not be started. Try again.',        icon: Icons.error_outline_rounded,      );    }    return switch (state.valueOrNull) {      KycLaunchOutcome.openedInNewTab => const _KycStatus(          tone: _KycTone.active,          step: 1,          label: 'In progress',          line: 'Finish in the secure tab, then check your status.',          icon: Icons.open_in_new_rounded,        ),      KycLaunchOutcome.popupBlocked => const _KycStatus(          tone: _KycTone.attention,          step: 1,          label: 'Action needed',          line: 'Your browser blocked the tab. Open it to continue.',          icon: Icons.warning_amber_rounded,        ),      KycLaunchOutcome.submitted => const _KycStatus(          tone: _KycTone.review,          step: 2,          label: 'In review',          line: 'Your documents are with our verification partner.',          icon: Icons.hourglass_bottom_rounded,        ),      null => const _KycStatus(          tone: _KycTone.pending,          step: 0,          label: 'Not started',          line: 'Three steps, about two minutes.',          icon: Icons.shield_outlined,        ),    };  }   /// Semantic register of the status. Carried by the pill and the glyph.  final _KycTone tone;   /// Index of the step the customer is on, 0 to 2.  final int step;   /// Pill label. Kept short so the header holds at 375 px and text scale 1.3.  final String label;   /// One line under the eyebrow saying what happens next.  final String line;   final IconData icon;   /// Brand token for [tone]; `CardeoPill` and `CardeoIconTile` deepen it for  /// paper themselves, so the same token is right in both themes.  Color get color => switch (tone) {        _KycTone.pending => CardeoColors.iris,        _KycTone.active => CardeoColors.iris,        _KycTone.review => CardeoColors.teal,        _KycTone.attention => CardeoColors.warning,      };} /// The verification journey. Three fixed steps; the current one is the only/// thing on the panel wearing iris.const List<(String, String)> _kycSteps = [  ('Your details', 'Occupation, income and account purpose'),  ('Identity documents', 'Photo ID and a selfie, in a secure window'),  ('Review', 'Our verification partner confirms your documents'),]; /// Status and progress in one panel: the eyebrow, the live status line, the/// status pill, then the vertical step bar under a hairline.////// The panel is the screen's single sheen host — a progress bar is one of the/// allowed ones — and it never sweeps on arrival, so the screen stays a/// utility screen.class _KycProgressPanel extends StatelessWidget {  const _KycProgressPanel({required this.status});   final _KycStatus status;   @override  Widget build(BuildContext context) {    return CardeoGlassPanel(      radius: AppRadii.lg,      padding: const EdgeInsets.fromLTRB(        AppSpacing.md,        AppSpacing.md,        AppSpacing.md,        AppSpacing.md,      ),      child: Column(        crossAxisAlignment: CrossAxisAlignment.stretch,        children: [          _KycStatusHeader(status: status),          const SizedBox(height: AppSpacing.sm),          SizedBox(            height: 1,            child: ColoredBox(              color: CardeoBorders.hairlineSideOf(context).color,            ),          ),          const SizedBox(height: AppSpacing.md),          // The step bar is the host, not the whole panel: the sheen's clip          // would otherwise cut the panel's daylight ambient shadow, and the          // band belongs on the progress, not on the status line.          CardeoSheen(            borderRadius: BorderRadius.circular(AppRadii.xs),            intensity: CardeoSheenIntensity.soft,            sweepOnArrival: false,            child: Column(              crossAxisAlignment: CrossAxisAlignment.stretch,              children: [                for (var index = 0; index < _kycSteps.length; index++)                  _KycStepRow(                    index: index,                    title: _kycSteps[index].$1,                    detail: _kycSteps[index].$2,                    done: index < status.step,                    current: index == status.step,                    last: index == _kycSteps.length - 1,                  ),              ],            ),          ),        ],      ),    );  }} class _KycStatusHeader extends StatelessWidget {  const _KycStatusHeader({required this.status});   final _KycStatus status;   @override  Widget build(BuildContext context) => Row(        children: [          CardeoStateSwitch(            child: CardeoIconTile(              key: ValueKey(status.icon.codePoint),              icon: status.icon,              color: status.color,              size: 34,            ),          ),          const SizedBox(width: AppSpacing.sm),          Expanded(            child: Column(              mainAxisSize: MainAxisSize.min,              crossAxisAlignment: CrossAxisAlignment.start,              children: [                Text(context.tr('VERIFICATION'),                    style: CardeoTextStyles.label(context)),                const SizedBox(height: 2),                Semantics(                  liveRegion: true,                  child: CardeoStateSwitch(                    alignment: Alignment.centerLeft,                    child: Text(                      context.tr(status.line),                      key: ValueKey(status.line),                      maxLines: 2,                      overflow: TextOverflow.ellipsis,                      style: TextStyle(                        fontSize: 12.5,                        height: 1.35,                        color: CardeoInk.secondary(context),                      ),                    ),                  ),                ),              ],            ),          ),          const SizedBox(width: AppSpacing.xs),          CardeoStateSwitch(            child: CardeoPill(              key: ValueKey(status.label),              label: context.tr(status.label),              color: status.color,              dot: true,            ),          ),        ],      );} /// One step of the vertical progress: a 20 pt marker, a one-pixel connector/// down to the next marker, and the step's own text.////// The connector is drawn by the row above it and stretches to whatever/// height the text needs, so the bar stays joined at text scale 1.3.class _KycStepRow extends StatelessWidget {  const _KycStepRow({    required this.index,    required this.title,    required this.detail,    required this.done,    required this.current,    required this.last,  });   /// Zero-based position in [_kycSteps]. A step that is neither done nor  /// current shows this number, so the three states are told apart by shape  /// — tick, ring-and-dot, numeral — and not only by hue.  final int index;   final String title;  final String detail;  final bool done;  final bool current;  final bool last;   /// Width of the marker column. The marker is 20; the connector runs down  /// its centre.  static const double _marker = 20;   @override  Widget build(BuildContext context) {    final accent = CardeoInk.accent(context, CardeoColors.iris);    final settled = CardeoInk.accent(context, CardeoColors.success);    final hairline = CardeoBorders.hairlineSideOf(context).color;    return Semantics(      label: 'Step ${index + 1} of ${_kycSteps.length}. $title. '          '${done ? 'Done' : current ? 'You are here' : 'Not started'}. '          '$detail',      child: ExcludeSemantics(        child: IntrinsicHeight(          child: Row(            crossAxisAlignment: CrossAxisAlignment.stretch,            children: [              SizedBox(                width: _marker,                child: Column(                  children: [                    _KycStepMarker(                      index: index,                      done: done,                      current: current,                      accent: accent,                      settled: settled,                      hairline: hairline,                    ),                    if (!last)                      Expanded(                        child: Container(                          width: 1,                          color: done ? settled : hairline,                        ),                      ),                  ],                ),              ),              const SizedBox(width: AppSpacing.sm),              Expanded(                child: Padding(                  padding: EdgeInsets.only(                    bottom: last ? 0 : AppSpacing.md,                  ),                  child: Column(                    mainAxisSize: MainAxisSize.min,                    crossAxisAlignment: CrossAxisAlignment.start,                    children: [                      Text(                        title,                        style: TextStyle(                          fontSize: 13.5,                          height: 1.3,                          fontWeight:                              current ? FontWeight.w700 : FontWeight.w600,                          color: CardeoInk.primary(context),                        ),                      ),                      const SizedBox(height: 2),                      Text(                        detail,                        style: TextStyle(                          fontSize: 12,                          height: 1.35,                          color: CardeoInk.secondary(context),                        ),                      ),                    ],                  ),                ),              ),            ],          ),        ),      ),    );  }} /// Done is a settled tick, current is an iris ring, next is a hairline ring./// Only one marker on the panel is iris at a time.class _KycStepMarker extends StatelessWidget {  const _KycStepMarker({    required this.index,    required this.done,    required this.current,    required this.accent,    required this.settled,    required this.hairline,  });   final int index;  final bool done;  final bool current;  final Color accent;  final Color settled;  final Color hairline;   @override  Widget build(BuildContext context) {    if (done) {      return Container(        width: _KycStepRow._marker,        height: _KycStepRow._marker,        decoration: BoxDecoration(          shape: BoxShape.circle,          color: CardeoInk.tint(context, CardeoColors.success, alpha: .16),        ),        child: Icon(Icons.check_rounded, size: 12, color: settled),      );    }    if (current) {      return Container(        width: _KycStepRow._marker,        height: _KycStepRow._marker,        decoration: BoxDecoration(          shape: BoxShape.circle,          color: CardeoInk.tint(context, CardeoColors.iris, alpha: .12),          border: Border.all(color: accent, width: 2),        ),        child: Center(          child: Container(            width: 6,            height: 6,            decoration: BoxDecoration(shape: BoxShape.circle, color: accent),          ),        ),      );    }    // Not started. An empty hairline ring is ambiguous — it reads as a    // disabled control rather than as step three — so the numeral goes in.    // Tertiary ink measures 5.15:1 on Twilight and 4.58:1 on paper, body    // grade rather than the 3:1 an icon would have been held to.    return Container(      width: _KycStepRow._marker,      height: _KycStepRow._marker,      alignment: Alignment.center,      decoration: BoxDecoration(        shape: BoxShape.circle,        border: Border.all(color: hairline),      ),      child: Text(        '${index + 1}',        style: TextStyle(          fontSize: 10.5,          height: 1,          fontWeight: FontWeight.w600,          // Secondary rather than tertiary ink: at 10.5 px this numeral is          // body-grade text, and tertiary on the daylight panel lands at          // about 4.5:1 — exactly on the floor, with no margin for the          // gradient's darker stop. Secondary measures 7.79:1 / 7.30:1          // across the daylight panel and 7.41:1 / 7.76:1 across the          // Twilight one, and the ring plus the size still keep it the          // quietest marker of the three.          color: CardeoInk.secondary(context),          fontFeatures: const [FontFeature.tabularFigures()],        ),      ),    );  }} class _LoadingField extends StatelessWidget {  const _LoadingField({required this.label});   final String label;   @override  Widget build(BuildContext context) {    if (context.isCardeoTheme) {      // The question stays readable while its options load, and the      // placeholder reserves the 56 pt the filled control will occupy, so      // nothing below it jumps when the list arrives. A spinner would      // animate a utility screen and still say nothing about what is      // loading.      return _KycField(        label: label,        why: 'Loading the list.',        child: CardeoSkeleton(          height: 56,          radius: AppRadii.sm,          semanticsLabel: context.tr('Loading options'),        ),      );    }    return InputDecorator(      decoration: InputDecoration(labelText: label),      child: Row(        children: [          const SizedBox.square(            dimension: 16,            child: AppProgressIndicator(strokeWidth: 2),          ),          const SizedBox(width: 12),          Text(context.tr('Loading options')),        ],      ),    );  }} class _OptionsError extends StatelessWidget {  const _OptionsError({    required this.message,    required this.onRetry,  });   final String message;  final VoidCallback onRetry;   @override  Widget build(BuildContext context) {    if (context.isCardeoTheme) {      return CardeoGlassPanel(        radius: AppRadii.sm,        padding: const EdgeInsets.all(AppSpacing.sm),        child: Row(          crossAxisAlignment: CrossAxisAlignment.start,          children: [            const CardeoIconTile(              icon: Icons.error_outline_rounded,              color: CardeoColors.danger,              size: 30,              radius: 9,            ),            const SizedBox(width: AppSpacing.sm),            Expanded(              child: Column(                crossAxisAlignment: CrossAxisAlignment.start,                children: [                  Text(                    context.tr('Could not load occupations'),                    style: TextStyle(                      fontSize: 13.5,                      fontWeight: FontWeight.w600,                      height: 1.3,                      color: CardeoInk.primary(context),                    ),                  ),                  const SizedBox(height: 2),                  Text(                    message,                    maxLines: 3,                    overflow: TextOverflow.ellipsis,                    style: TextStyle(                      fontSize: 12,                      height: 1.35,                      color: CardeoInk.secondary(context),                    ),                  ),                  const SizedBox(height: AppSpacing.xxs),                  TextButton(                    style: TextButton.styleFrom(                      foregroundColor:                          CardeoInk.accent(context, CardeoColors.iris),                      minimumSize: const Size(0, 44),                      padding: const EdgeInsets.symmetric(                        horizontal: AppSpacing.xs,                      ),                    ),                    onPressed: onRetry,                    child: Text(context.tr('Retry')),                  ),                ],              ),            ),          ],        ),      );    }    return Card(      color: Theme.of(context).colorScheme.errorContainer,      child: Padding(        padding: const EdgeInsets.all(12),        child: Column(          crossAxisAlignment: CrossAxisAlignment.start,          children: [            Text(              context.tr('Could not load occupations'),              style: Theme.of(context).textTheme.titleSmall,            ),            const SizedBox(height: 4),            Text(message, maxLines: 3, overflow: TextOverflow.ellipsis),            const SizedBox(height: 8),            OutlinedButton.icon(              onPressed: onRetry,              icon: const Icon(Icons.refresh),              label: Text(context.tr('Retry')),            ),          ],        ),      ),    );  }} class _RequiredTextField extends StatelessWidget {  const _RequiredTextField({    required this.controller,    required this.label,    required this.hint,  });   final TextEditingController controller;  final String label;  final String hint;   @override  Widget build(BuildContext context) {    return TextFormField(      controller: controller,      // Cardeo states the question above the control, so the floating label      // here would be the same words twice.      decoration: context.isCardeoTheme          ? InputDecoration(hintText: hint)          : InputDecoration(labelText: label, hintText: hint),      validator: (value) {        if (value == null || value.trim().isEmpty) {          return context.tr('{p0} is required', {'p0': label});        }         return null;      },    );  }} /// One question in the verification form, Cardeo only.////// The label sits above the control at full size instead of floating inside/// it. In a KYC form that is not a style preference. A Material floating/// label shrinks to about 12 px the moment the field has a value, so the/// question a customer is answering becomes the smallest text on the screen/// exactly while they decide whether to answer it — and this is the one form/// in the product where people stop and decide. Lifting the label also buys/// the line that does the real work here, [why], which says what the answer/// is for, in the place a help icon would otherwise hide it.////// Screen readers hear the question and its reason once, as the field's own/// name, rather than three times over from three sibling nodes.class _KycField extends StatelessWidget {  const _KycField({    required this.label,    required this.why,    required this.child,  });   final String label;   /// What the answer is used for. One sentence, never a policy paragraph.  final String why;   final Widget child;   @override  Widget build(BuildContext context) => Column(        crossAxisAlignment: CrossAxisAlignment.stretch,        children: [          ExcludeSemantics(            child: Text(              label,              style: TextStyle(                fontSize: 13,                height: 1.3,                fontWeight: FontWeight.w600,                color: CardeoInk.primary(context),              ),            ),          ),          const SizedBox(height: 2),          ExcludeSemantics(            child: Text(              why,              style: TextStyle(                fontSize: 11.5,                height: 1.4,                // Secondary, not tertiary. This is a sentence, and the page                // ground under it is the shell atmosphere, where daylight                // tertiary measures 4.28:1 on the bright half — under the                // body floor. Secondary holds 6.70:1 on that same peak and                // 7.49:1 on flat paper. Size and weight, not colour, keep it                // quieter than the question above it.                color: CardeoInk.secondary(context),              ),            ),          ),          const SizedBox(height: AppSpacing.xs),          Semantics(label: '$label. $why', child: child),        ],      );} /// What the secure window will ask for, and where each document has got to.////// Hosted identity verification hands a customer to a third party with no/// warning about what they are about to be asked to photograph; this states/// it first. The list is deliberately two items long — a checklist that/// cannot be finished in one sitting is a reason to abandon the flow.class _KycDocumentChecklist extends StatelessWidget {  const _KycDocumentChecklist({required this.status});   final _KycStatus status;   static const List<(IconData, String, String)> _documents = [    (      Icons.badge_outlined,      'Photo ID',      'Passport, national ID card or driving licence',    ),    (      Icons.face_retouching_natural_outlined,      'Selfie',      'A short liveness check in the same window',    ),  ];   @override  Widget build(BuildContext context) {    final state = _KycDocState.from(status);    return Column(      crossAxisAlignment: CrossAxisAlignment.stretch,      children: [        CardeoSectionTitle(title: context.tr('What you will need')),        const SizedBox(height: AppSpacing.sm),        for (var index = 0; index < _documents.length; index++) ...[          if (index > 0) const SizedBox(height: AppSpacing.xs),          _KycDocumentTile(            icon: _documents[index].$1,            title: _documents[index].$2,            detail: _documents[index].$3,            state: state,          ),        ],      ],    );  }} /// Where one document has got to.////// The state a hosted flow can actually report, named honestly. There is no/// per-document rejection here because the launcher does not return one; the/// [attention] register is where that copy will land the day the partner's/// status is available, and it is already written as *what to fix* rather/// than as "invalid".enum _KycDocState {  /// Not asked for yet. Drawn as an outline, so an empty slot looks empty.  waiting,   /// The secure window is open and waiting for this document.  active,   /// Handed to the verification partner.  received,   /// Something is in the way and the customer can clear it.  attention;   static _KycDocState from(_KycStatus status) {    if (status.step >= 2) return _KycDocState.received;    if (status.tone == _KycTone.attention && status.step >= 1) {      return _KycDocState.attention;    }    if (status.step >= 1) return _KycDocState.active;    return _KycDocState.waiting;  }} /// One document, in one of four designed states.////// Empty and filled are told apart by *material*, not by a tint: waiting is a/// dashed outline with no fill, everything after it is a solid panel. Radius/// and padding are pinned to the same numbers in both, so a document tile/// never moves or resizes as it fills — the checklist settles rather than/// reflowing under the reader.class _KycDocumentTile extends StatelessWidget {  const _KycDocumentTile({    required this.icon,    required this.title,    required this.detail,    required this.state,  });   final IconData icon;  final String title;  final String detail;  final _KycDocState state;   /// Shared silhouette. [CardeoDashedPanel] defaults to exactly these, and  /// the solid states are pinned to them so nothing shifts on a state change.  static const double _radius = 16;  static const EdgeInsets _padding =      EdgeInsets.symmetric(horizontal: 14, vertical: 12);   @override  Widget build(BuildContext context) {    final line = switch (state) {      _KycDocState.waiting => detail,      _KycDocState.active => 'Photograph it in the secure window.',      _KycDocState.received => 'Sent to our verification partner.',      _KycDocState.attention =>        'The secure window is not open. Allow pop-ups for this site, then '            'start again.',    };    final (String? pill, Color pillColor) = switch (state) {      _KycDocState.waiting => (null, CardeoColors.iris),      _KycDocState.active => ('Ready', CardeoColors.iris),      _KycDocState.received => ('Received', CardeoColors.teal),      _KycDocState.attention => ('Action needed', CardeoColors.warning),    };     final body = Semantics(      label: '$title. ${switch (state) {        _KycDocState.waiting => 'Not provided yet',        _KycDocState.active => 'Ready to capture',        _KycDocState.received => 'Received',        _KycDocState.attention => 'Action needed',      }}. $line',      child: ExcludeSemantics(        child: Row(          children: [            _DocumentGlyph(icon: icon, state: state),            const SizedBox(width: AppSpacing.sm),            Expanded(              child: Column(                mainAxisSize: MainAxisSize.min,                crossAxisAlignment: CrossAxisAlignment.start,                children: [                  Text(                    title,                    style: TextStyle(                      fontSize: 13.5,                      height: 1.3,                      fontWeight: FontWeight.w600,                      color: CardeoInk.primary(context),                    ),                  ),                  const SizedBox(height: 2),                  // The line swaps in place through the 200 ms state fade;                  // both variants live in the same text slot, so the tile                  // height is set by the longest of them and the checklist                  // never reflows as the flow advances.                  CardeoStateSwitch(                    alignment: Alignment.centerLeft,                    child: Text(                      line,                      key: ValueKey(line),                      style: TextStyle(                        fontSize: 11.5,                        height: 1.4,                        // Secondary ink, measured against the panel's own                        // gradient rather than a flat swatch: 7.79:1 at the                        // daylight top stop and 7.30:1 at the bottom, 7.41:1                        // and 7.76:1 on Twilight. Body grade everywhere on                        // the tile, in both themes.                        color: CardeoInk.secondary(context),                      ),                    ),                  ),                ],              ),            ),            if (pill != null) ...[              const SizedBox(width: AppSpacing.xs),              CardeoStateSwitch(                child: CardeoPill(                  key: ValueKey(pill),                  label: pill,                  color: pillColor,                  dot: state == _KycDocState.active,                ),              ),            ],          ],        ),      ),    );     if (state == _KycDocState.waiting) {      return CardeoDashedPanel(        radius: _radius,        padding: _padding,        child: body,      );    }    return CardeoGlassPanel(      radius: _radius,      padding: _padding,      child: body,    );  }} /// The document's own glyph, in the state's register. Waiting is a bare/// outline in the resting ink — a slot that has not been filled should not/// wear a colour — and the three live states take the tinted tile.class _DocumentGlyph extends StatelessWidget {  const _DocumentGlyph({required this.icon, required this.state});   final IconData icon;  final _KycDocState state;   @override  Widget build(BuildContext context) {    if (state == _KycDocState.waiting) {      return SizedBox.square(        dimension: 34,        child: Icon(          icon,          size: 20,          // Secondary ink on the dashed tile: 7.30:1 at the darkest point          // of the daylight fill and 7.41:1 at the darkest point of the          // Twilight one, against a 3:1 floor for a glyph.          color: CardeoInk.secondary(context),        ),      );    }    return CardeoStateSwitch(      child: CardeoIconTile(        key: ValueKey(state),        icon: switch (state) {          _KycDocState.received => Icons.check_rounded,          _KycDocState.attention => Icons.priority_high_rounded,          _ => icon,        },        color: switch (state) {          _KycDocState.received => CardeoColors.teal,          _KycDocState.attention => CardeoColors.warning,          _ => CardeoColors.iris,        },        size: 34,      ),    );  }} /// A launch that failed, answered where it failed.////// Keeps the error visible alongside the original snackbar feedback.class _KycFailureNotice extends StatelessWidget {  const _KycFailureNotice({required this.error});   final Object error;   /// The two things that actually cause this, in the order they are worth  /// trying. Neither mentions a status code.  static const List<String> _fixes = [    'Allow pop-ups for this site, then start again.',    'On a work network, a VPN or firewall can block the secure window.',  ];   @override  Widget build(BuildContext context) => Semantics(        liveRegion: true,        child: CardeoGlassPanel(          radius: AppRadii.lg,          padding: const EdgeInsets.all(AppSpacing.md),          child: Column(            crossAxisAlignment: CrossAxisAlignment.stretch,            children: [              Row(                crossAxisAlignment: CrossAxisAlignment.start,                children: [                  const CardeoIconTile(                    icon: Icons.error_outline_rounded,                    color: CardeoColors.danger,                    size: 34,                  ),                  const SizedBox(width: AppSpacing.sm),                  Expanded(                    child: Column(                      crossAxisAlignment: CrossAxisAlignment.start,                      children: [                        Text(                          context.tr('Verification did not start'),                          style: TextStyle(                            fontSize: 14,                            height: 1.3,                            fontWeight: FontWeight.w700,                            color: CardeoInk.primary(context),                          ),                        ),                        const SizedBox(height: 2),                        Text(                          friendlyErrorMessage(error),                          style: TextStyle(                            fontSize: 12,                            height: 1.4,                            color: CardeoInk.secondary(context),                          ),                        ),                      ],                    ),                  ),                ],              ),              const SizedBox(height: AppSpacing.sm),              for (final fix in _fixes)                Padding(                  padding: const EdgeInsets.only(bottom: AppSpacing.xxs),                  child: Row(                    crossAxisAlignment: CrossAxisAlignment.start,                    children: [                      Padding(                        padding: const EdgeInsets.only(top: 2),                        child: Icon(                          Icons.arrow_forward_rounded,                          size: 13,                          color: CardeoInk.secondary(context),                        ),                      ),                      const SizedBox(width: AppSpacing.xs),                      Expanded(                        child: Text(                          fix,                          style: TextStyle(                            fontSize: 12,                            height: 1.4,                            color: CardeoInk.secondary(context),                          ),                        ),                      ),                    ],                  ),                ),            ],          ),        ),      );} /// The three sentences a customer wants before they photograph a passport.////// Not a card. Every competitor answers this anxiety with either nothing or/// a wall of legalese behind a "Learn more"; the material answer is a/// hairline-topped footnote in the resting palette that can be read in four/// seconds and never asks to be tapped. Reassurance that shouts stops being/// reassurance.class _KycAssurancePanel extends StatelessWidget {  const _KycAssurancePanel();   static const List<(IconData, String)> _lines = [    (      Icons.shield_outlined,      'Required by law before an account can hold or send money.',    ),    (      Icons.lock_outline_rounded,      'Sent over an encrypted connection to a regulated verification partner.',    ),    (Icons.schedule_rounded, 'Most reviews finish in minutes.'),  ];   @override  Widget build(BuildContext context) => Column(        crossAxisAlignment: CrossAxisAlignment.stretch,        children: [          SizedBox(            height: 1,            child: ColoredBox(              color: CardeoBorders.hairlineSideOf(context).color,            ),          ),          const SizedBox(height: AppSpacing.sm),          for (var index = 0; index < _lines.length; index++) ...[            if (index > 0) const SizedBox(height: AppSpacing.xs),            Row(              crossAxisAlignment: CrossAxisAlignment.start,              children: [                Padding(                  padding: const EdgeInsets.only(top: 1),                  child: Icon(                    _lines[index].$1,                    size: 15,                    // Secondary ink over the page and its atmosphere:                    // 6.70:1 at the brightest daylight peak, well past the                    // 3:1 floor for a glyph.                    color: CardeoInk.secondary(context),                  ),                ),                const SizedBox(width: AppSpacing.xs),                Expanded(                  child: Text(                    _lines[index].$2,                    style: TextStyle(                      fontSize: 12,                      height: 1.4,                      color: CardeoInk.secondary(context),                    ),                  ),                ),              ],            ),          ],        ],      );} /// The hand-off to the hosted verification window, Cardeo only.////// A blocked pop-up is the single failure this flow actually hits, and a/// stock alert answers it with a title and a paragraph. This answers it with/// the fix and the button that applies it: the primary action is whichever/// one moves the customer forward from where they are — open the window when/// it never opened, check the status when it did.class _KycHandoffDialog extends StatelessWidget {  const _KycHandoffDialog({    required this.blocked,    required this.onOpen,    required this.onCheckStatus,  });   final bool blocked;  final Future<void> Function() onOpen;  final VoidCallback onCheckStatus;   /// The launcher as a plain callback, so the two CTAs can share one  /// conditional without the ternary widening to `Function`.  void _open() => unawaited(onOpen());   @override  Widget build(BuildContext context) => Dialog(        backgroundColor: Colors.transparent,        elevation: 0,        // 24 rather than the Material 40, so the panel is 327 wide at 375        // and the two stacked CTAs never crowd their labels.        insetPadding: const EdgeInsets.all(AppSpacing.lg),        child: ConstrainedBox(          constraints: const BoxConstraints(maxWidth: 420),          child: DecoratedBox(            decoration: BoxDecoration(              borderRadius: BorderRadius.circular(AppRadii.lg),              boxShadow: CardeoShadows.sheetOf(context),            ),            child: CardeoGlassPanel(              radius: AppRadii.lg,              padding: const EdgeInsets.all(AppSpacing.lg),              child: SingleChildScrollView(                child: Column(                  mainAxisSize: MainAxisSize.min,                  crossAxisAlignment: CrossAxisAlignment.stretch,                  children: [                    Align(                      alignment: AlignmentDirectional.centerStart,                      child: CardeoIconTile(                        icon: blocked                            ? Icons.tab_unselected_rounded                            : Icons.open_in_new_rounded,                        color:                            blocked ? CardeoColors.warning : CardeoColors.teal,                        size: 38,                        radius: 12,                      ),                    ),                    const SizedBox(height: AppSpacing.sm),                    Text(                      blocked                          ? context.tr('Your browser blocked the window')                          : context.tr('Verification is open in a new tab'),                      style: TextStyle(                        fontSize: 17,                        height: 1.25,                        fontWeight: FontWeight.w700,                        color: CardeoInk.primary(context),                      ),                    ),                    const SizedBox(height: AppSpacing.xs),                    Text(                      blocked                          ? context.tr(                              'Nothing has gone wrong with your application. Allow pop-ups for this site, or open the secure window from here.')                          : context.tr(                              'Finish the checks there, then come back and check your status. You can close this and return at any time.'),                      style: TextStyle(                        fontSize: 13,                        height: 1.45,                        color: CardeoInk.secondary(context),                      ),                    ),                    const SizedBox(height: AppSpacing.lg),                    // Primary is whichever action moves this customer on                    // from where they actually are, not a fixed "OK".                    CardeoGlassButton(                      label: blocked                          ? context.tr('Open verification')                          : context.tr('Check status'),                      icon: blocked                          ? Icons.open_in_new_rounded                          : Icons.task_alt_rounded,                      onPressed: blocked ? _open : onCheckStatus,                    ),                    const SizedBox(height: AppSpacing.xs),                    CardeoGlassButton(                      label: blocked                          ? context.tr('Check status')                          : context.tr('Open again'),                      tone: CardeoGlassButtonTone.neutral,                      onPressed: blocked ? onCheckStatus : _open,                    ),                  ],                ),              ),            ),          ),        ),      );}
HOPPA DEVELOPER ASSISTANT

What are you building?

From a question to an implementation.

Get a step-by-step flow, example code and the docs behind it. Choose a starting point or describe your feature.

Answers use the public API guide and example app code. Check the linked reference before shipping. Explore code sources ↗

Your question and recent conversation are sent to our AI provider. Don’t include API keys, personal data or card details.