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

onboarding_banking_screen.dart

mobile_flutter/lib/features/platform/presentation/onboarding_banking_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–1764

import 'personal_onboarding_localizations.dart';import '../../kyc/presentation/unified_cdd_screen.dart';import 'package:mobile_flutter/core/l10n/app_localizations.dart';import '../../../core/models/upload_document.dart';import '../../../shared/widgets/safeguarding_statement.dart';import 'package:flutter/material.dart';import 'package:flutter_riverpod/flutter_riverpod.dart';import 'package:file_picker/file_picker.dart';import 'package:go_router/go_router.dart';import 'package:url_launcher/url_launcher.dart'; import '../../../app/routes.dart';import '../../../brands/cardeo/cardeo.dart';import '../../../core/models/banking_models.dart';import '../../../core/models/equals_money.dart';import '../../../core/models/platform_models.dart';import '../../../core/widgets/app_states.dart';import '../../../shared/widgets/additional_information_dialog.dart';import '../../../shared/widgets/searchable_multi_select_dropdown.dart';import '../../banking/application/banking_providers.dart';import '../application/platform_providers.dart';import 'platform_widgets.dart';import 'tiers_screen.dart' show tierIdOf;import '../../profile/presentation/security_sheets.dart'    show showCardeoSheet, CardeoSheetHeader, CardeoSheetNote, CardeoSheetCta;import '../../../shared/widgets/app_progress_indicator.dart'; /// Providers and budgets: the EqualsMoney onboarding flow.////// The flow is unchanged — same guards, same actions, same dialogs. What is/// new is the frame: a setup panel whose step bar is the screen's one moment/// (a single sheen pass along the 2 pt hairline once the route settles), the/// actions as rows in a `CardeoListGroup`, and the single next step lifted out/// of the list into a frosted bar pinned above the safe area, so the thing to/// do next is always on screen.class OnboardingBankingScreen extends ConsumerWidget {  const OnboardingBankingScreen({super.key});   @override  Widget build(BuildContext context, WidgetRef ref) {    final tenantConfig = ref.watch(mobileTenantConfigProvider);    if (tenantConfig.isLoading) {      return Scaffold(        appBar: AppBar(title: Text(context.tr('Providers and budgets'))),        body: LoadingState(label: context.tr('Loading banking configuration')),      );    }    final config = tenantConfig.valueOrNull;    if (config == null || !config.equalsMoneyEnabled) {      return Scaffold(        appBar: AppBar(title: Text(context.tr('Providers and budgets'))),        body: Padding(          padding: const EdgeInsets.all(16),          child: EmptyState(            title: context.tr('Banking provider unavailable'),            message: context.tr(                'EqualsMoney onboarding is not enabled for this white-label installation.'),            icon: Icons.account_balance_outlined,          ),        ),      );    }     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 &&        ref.watch(kycDetailedStatusProvider).valueOrNull?.isBankApproved != true) {      return const UnifiedCddScreen();    }     return PlatformActionListener(      child: Scaffold(        appBar: AppBar(title: Text(context.tr('Providers and budgets'))),        body: context.isCardeoTheme ? const _CardeoBody() : const _LegacyBody(),      ),    );  }} class _CardeoBody extends ConsumerWidget {  const _CardeoBody();   @override  Widget build(BuildContext context, WidgetRef ref) {    final kycStatus = ref.watch(kycDetailedStatusProvider);    final dashboard = ref.watch(dashboardProvider);    final isBusinessAccount =        dashboard.valueOrNull?.profile.isBusinessAccount ?? false;    final status = kycStatus.valueOrNull;    final onboarded = status?.canUseEqualsMoneyBanking ?? false;    final needsInput = status?.requiresEqualsMoneyAction ?? false;    final inReview = status?.hasEqualsMoneyReviewState ?? false;    final accountId = status?.equalsMoneyAccountId;     return Column(      children: [        Expanded(          child: ListView(            padding: platformCardeoPadding,            children: [              _SetupPanel(status: kycStatus),              platformSectionGap,              if (needsInput && status != null) ...[                CardeoSectionTitle(title: context.tr('Required now')),                const SizedBox(height: AppSpacing.sm),                _RequiredActionPanel(status: status),                platformSectionGap,              ],              CardeoListGroup(                title: needsInput                    ? context.tr('Also here')                    : context.tr('Next actions'),                children: [                  CardeoRow(                    leading: CardeoIconTile(                      icon: _providerIcon(                        onboarded: onboarded,                        needsInput: needsInput,                        inReview: inReview,                      ),                      color: CardeoColors.iris,                    ),                    title: context.tr('EqualsMoney'),                    subtitle: !isBusinessAccount &&                            status?.isHoppaApproved != true &&                            !onboarded &&                            !needsInput &&                            !inReview                        ? context.tr(                            'Complete identity verification before banking setup')                        : _providerCaption(                            onboarded: onboarded,                            needsInput: needsInput,                            inReview: inReview,                            isBusinessAccount: isBusinessAccount,                            accountId: accountId,                          ),                    trailing: _providerPill(                      context,                      onboarded: onboarded,                      needsInput: needsInput,                      inReview: inReview,                    ),                  ),                  if (!needsInput) ...[                    PlatformActionRow(                      icon: Icons.info_outline,                      label: context.tr('Banking info'),                      description: onboarded                          ? context.tr('View EqualsMoney account details')                          : context.tr('Available after EqualsMoney approval'),                      enabled: onboarded,                      onPressed: onboarded                          ? (context, ref) => ref                                  .read(                                      platformActionControllerProvider.notifier)                                  .run(                                (api) async {                                  final info = await api.getEqualsBankingInfo();                                   return ActionResult(                                    message: info.title,                                    reference: info.id,                                    metadata: info.metadata,                                  );                                },                              )                          : null,                    ),                    PlatformActionRow(                      icon: Icons.savings_outlined,                      label: context.tr('Create EUR budget'),                      description: onboarded                          ? context.tr('Set a monthly spending pool')                          : context.tr('Available after EqualsMoney approval'),                      enabled: onboarded,                      onPressed: onboarded                          ? (context, ref) => ref                              .read(platformActionControllerProvider.notifier)                              .run((api) => api.createBudget(                                    name: 'New budget',                                    currency: 'EUR',                                  ))                          : null,                    ),                    PlatformActionRow(                      icon: Icons.edit_outlined,                      label: context.tr('Update budget'),                      description: onboarded                          ? context.tr('Adjust budget settings')                          : context.tr('Available after EqualsMoney approval'),                      enabled: onboarded,                      onPressed: onboarded                          ? (context, ref) => ref                              .read(platformActionControllerProvider.notifier)                              .run(                                (api) => api.updateBudget(                                  budgetId: 'budget_ops',                                  name: 'Operations',                                  currency: 'EUR',                                ),                              )                          : null,                    ),                    PlatformActionRow(                      icon: Icons.compare_arrows,                      label: context.tr('Move budget funds'),                      description: onboarded                          ? context.tr('Transfer between budgets')                          : context.tr('Available after EqualsMoney approval'),                      enabled: onboarded,                      onPressed: onboarded                          ? (context, ref) => ref                              .read(platformActionControllerProvider.notifier)                              .run(                                (api) => api.transferBudget(                                  fromBudgetId: 'budget_ops',                                  toBudgetId: 'budget_cards',                                  amount: const Money(                                    currency: 'EUR',                                    minorUnits: 2500,                                  ),                                ),                              )                          : null,                    ),                  ],                ],              ),              if (!needsInput) ...[                platformSectionGap,                CardeoSectionTitle(title: context.tr('Providers')),                const SizedBox(height: AppSpacing.sm),                CardeoStateSwitch(                  alignment: Alignment.topCenter,                  child: ref.watch(providersProvider).when(                        data: (items) => ResourceList(                          key: const ValueKey('providers-data'),                          resources: onboarded ? items : const [],                          emptyTitle: onboarded                              ? context.tr('No providers connected')                              : context.tr('Provider details locked'),                          emptyMessage: onboarded                              ? context.tr(                                  'Provider details appear here after the first sync.')                              : context.tr(                                  'Complete EqualsMoney approval before account details are shown.'),                          icon: Icons.account_balance,                        ),                        error: (error, stackTrace) => PlatformErrorState(                          key: const ValueKey('providers-error'),                          error: error,                          onRetry: () => ref.invalidate(providersProvider),                        ),                        loading: () => PlatformLoadingGroup(                          key: const ValueKey('providers-loading'),                          rows: 2,                          label: context.tr('Loading providers'),                        ),                      ),                ),                platformSectionGap,                CardeoSectionTitle(title: context.tr('Budgets')),                const SizedBox(height: AppSpacing.sm),                CardeoStateSwitch(                  alignment: Alignment.topCenter,                  child: ref.watch(budgetsProvider).when(                        data: (items) => ResourceList(                          key: const ValueKey('budgets-data'),                          showSafeguarding: true,                          resources: onboarded ? items : const [],                          emptyTitle: onboarded                              ? context.tr('No budgets yet')                              : context.tr('Budgets locked'),                          emptyMessage: onboarded                              ? context                                  .tr('Create a budget to organise spending.')                              : context.tr(                                  'Budgets unlock after EqualsMoney approval.'),                          icon: Icons.savings_outlined,                        ),                        error: (error, stackTrace) => PlatformErrorState(                          key: const ValueKey('budgets-error'),                          error: error,                          onRetry: () => ref.invalidate(budgetsProvider),                        ),                        loading: () => PlatformLoadingGroup(                          key: const ValueKey('budgets-loading'),                          rows: 2,                          label: context.tr('Loading budgets'),                        ),                      ),                ),                const SafeguardingStatementButton(),              ],            ],          ),        ),        _StickyNextStep(          status: status,          isBusinessAccount: isBusinessAccount,          onboarded: onboarded,          needsInput: needsInput,          inReview: inReview,        ),      ],    );  }} IconData _providerIcon({  required bool onboarded,  required bool needsInput,  required bool inReview,}) {  if (onboarded) return Icons.check_circle_outline;  if (needsInput || inReview) return Icons.hourglass_top;  return Icons.account_balance;} String _providerCaption({  required bool onboarded,  required bool needsInput,  required bool inReview,  required bool isBusinessAccount,  required String? accountId,}) {  if (onboarded) return 'Account ${accountId ?? 'connected'} is ready';  if (needsInput) {    return 'Upload documents or finish the secure provider check';  }  if (inReview) return 'We update this screen when the provider approves it';  return isBusinessAccount      ? 'Open an EU/UK business account'      : 'Open your banking setup';} Widget _providerPill(  BuildContext context, {  required bool onboarded,  required bool needsInput,  required bool inReview,}) {  if (onboarded) {    return CardeoPill(      label: context.tr('Active'),      color: CardeoColors.success,      dot: true,    );  }  if (needsInput) {    return CardeoPill(      label: context.tr('Action needed'),      color: CardeoColors.warning,      dot: true,    );  }  if (inReview) {    return CardeoPill(      label: context.tr('In review'),      color: CardeoColors.warning,      dot: true,    );  }  return CardeoPill(label: context.tr('Not started'), color: CardeoColors.iris);} /// Setup progress: the count, the step bar, and the three checks as hairline/// rows on one surface. The step bar is the screen's only sheen host.class _SetupPanel extends StatelessWidget {  const _SetupPanel({required this.status});   final AsyncValue<KycDetailedStatus> status;   @override  Widget build(BuildContext context) {    final detailed = status.valueOrNull;    final total = detailed?.setupStepCount ?? 3;    final completed = detailed?.completedSetupStepCount;    final steps = <({String label, bool? done})>[      (        label: context.tr('Identity verification'),        done: detailed?.isHoppaApproved      ),      (label: context.tr('Bank KYC'), done: detailed?.isBankApproved),      (label: context.tr('Card issuer'), done: detailed?.isCardIssuerApproved),    ];    final side = CardeoBorders.hairlineSideOf(context);     return DecoratedBox(      decoration: BoxDecoration(        color: CardeoSurface.of(context, 1),        borderRadius: BorderRadius.circular(AppRadii.lg),        border: CardeoBorders.subtleOf(context),        boxShadow: CardeoShadows.ambientOf(context),      ),      child: Padding(        padding: const EdgeInsets.all(AppSpacing.md),        child: Column(          crossAxisAlignment: CrossAxisAlignment.stretch,          children: [            Row(              children: [                Expanded(                  child: Text(                    context.tr('Banking setup'),                    style: TextStyle(                      fontSize: 15.5,                      fontWeight: FontWeight.w700,                      color: CardeoInk.primary(context),                    ),                  ),                ),                const SizedBox(width: AppSpacing.xs),                Text(                  completed == null                      ? '—'                      : context                          .tr('{p0} of {p1}', {'p0': completed, 'p1': total}),                  style: TextStyle(                    fontSize: 12.5,                    fontWeight: FontWeight.w600,                    color: CardeoInk.secondary(context),                    fontFeatures: const [FontFeature.tabularFigures()],                  ),                ),              ],            ),            const SizedBox(height: AppSpacing.sm),            _StepBar(total: total, completed: completed ?? 0),            const SizedBox(height: AppSpacing.xs),            for (var i = 0; i < steps.length; i++)              DecoratedBox(                decoration: BoxDecoration(border: Border(top: side)),                child: Padding(                  padding: const EdgeInsets.symmetric(vertical: 10),                  child: Row(                    children: [                      Icon(                        steps[i].done == true                            ? Icons.check_circle_rounded                            : Icons.radio_button_unchecked,                        size: 16,                        color: steps[i].done == true                            ? CardeoInk.accent(context, CardeoColors.success)                            : CardeoInk.tertiary(context),                      ),                      const SizedBox(width: AppSpacing.xs),                      Expanded(                        child: Text(                          context.tr(steps[i].label),                          style: TextStyle(                            fontSize: 13,                            height: 1.35,                            color: CardeoInk.secondary(context),                          ),                        ),                      ),                    ],                  ),                ),              ),            if (detailed?.hasEqualsMoneyAccount ?? false)              DecoratedBox(                decoration: BoxDecoration(border: Border(top: side)),                child: Padding(                  padding: const EdgeInsets.only(top: 10),                  child: CardeoMono(                    'Account ${detailed!.equalsMoneyAccountId}',                    size: 11.5,                  ),                ),              ),          ],        ),      ),    );  }} /// The step hairline: one 2 pt segment per setup step, filled as each one/// lands, and the single sheen host on this screen.class _StepBar extends StatelessWidget {  const _StepBar({required this.total, required this.completed});   final int total;  final int completed;   @override  Widget build(BuildContext context) {    final palette = CardeoPalette.of(context);    return Semantics(      label: context.tr('Setup progress, {p0} of {p1} steps complete',          {'p0': completed, 'p1': total}),      child: CardeoSheen(        borderRadius: const BorderRadius.all(Radius.circular(1)),        intensity: CardeoSheenIntensity.soft,        child: Row(          children: [            for (var i = 0; i < total; i++) ...[              if (i > 0) const SizedBox(width: AppSpacing.xxs),              Expanded(                child: DecoratedBox(                  decoration: BoxDecoration(                    color: i < completed ? palette.fill : palette.borderSubtle,                    borderRadius: const BorderRadius.all(Radius.circular(1)),                  ),                  child: const SizedBox(height: 2),                ),              ),            ],          ],        ),      ),    );  }} /// What EqualsMoney is waiting for, on one surface: the message, then each/// requested document as a hairline row with its own action. The link that/// continues the provider check lives in the sticky bar, not here, so the/// screen never offers the same button twice.class _RequiredActionPanel extends ConsumerWidget {  const _RequiredActionPanel({required this.status});   final KycDetailedStatus status;   @override  Widget build(BuildContext context, WidgetRef ref) {    final documents = status.equalsMoneyAdditionalDocumentsRequested;    final side = CardeoBorders.hairlineSideOf(context);     return DecoratedBox(      decoration: BoxDecoration(        color: CardeoSurface.of(context, 1),        borderRadius: BorderRadius.circular(AppRadii.lg),        border: CardeoBorders.subtleOf(context),        boxShadow: CardeoShadows.ambientOf(context),      ),      child: Padding(        padding: const EdgeInsets.all(AppSpacing.md),        child: Column(          crossAxisAlignment: CrossAxisAlignment.start,          children: [            Row(              crossAxisAlignment: CrossAxisAlignment.start,              children: [                const CardeoIconTile(                  icon: Icons.assignment_late_outlined,                  color: CardeoColors.warning,                ),                const SizedBox(width: AppSpacing.sm),                Expanded(                  child: Column(                    crossAxisAlignment: CrossAxisAlignment.start,                    children: [                      Text(                        context.tr('EqualsMoney needs your input'),                        style: TextStyle(                          fontSize: 15,                          fontWeight: FontWeight.w700,                          height: 1.3,                          color: CardeoInk.primary(context),                        ),                      ),                      const SizedBox(height: AppSpacing.xxs),                      Text(                        context.tr(_equalsMoneyActionMessage(                          hasDocuments: documents.isNotEmpty,                          hasActionUrl: status.hasEqualsMoneyActionUrl,                          requiredAction: status.equalsMoneyRequiredAction,                        )),                        style: TextStyle(                          fontSize: 13,                          height: 1.45,                          color: CardeoInk.secondary(context),                        ),                      ),                    ],                  ),                ),              ],            ),            for (final document in documents)              DecoratedBox(                decoration: BoxDecoration(border: Border(top: side)),                child: Padding(                  padding: const EdgeInsets.only(top: AppSpacing.sm),                  child: _CardeoDocumentRow(document: document),                ),              ),          ],        ),      ),    );  }} class _CardeoDocumentRow extends ConsumerWidget {  const _CardeoDocumentRow({required this.document});   final EqualsMoneyAdditionalDocumentRequest document;   @override  Widget build(BuildContext context, WidgetRef ref) {    final action = ref.watch(platformActionControllerProvider);    final details = document.expectsFiles        ? [            document.type,            if (document.associatedPersonName != null)              document.associatedPersonName!,            if (document.associatedPersonEmail != null)              document.associatedPersonEmail!,            if (document.additionalInformation != null)              document.additionalInformation!,          ].join(' · ')        : null;     return Padding(      padding: const EdgeInsets.only(bottom: AppSpacing.sm),      child: Row(        crossAxisAlignment: CrossAxisAlignment.start,        children: [          const CardeoIconTile(            icon: Icons.description_outlined,            color: CardeoColors.iris,          ),          const SizedBox(width: AppSpacing.sm),          Expanded(            child: Column(              crossAxisAlignment: CrossAxisAlignment.start,              children: [                Text(                  _equalsMoneyRequestLabel(document.type, document.text),                  style: TextStyle(                    fontSize: 13.5,                    fontWeight: FontWeight.w600,                    height: 1.35,                    color: CardeoInk.primary(context),                  ),                ),                if (details != null && details.trim().isNotEmpty) ...[                  const SizedBox(height: 2),                  Text(                    details,                    style: TextStyle(                      fontSize: 12,                      height: 1.4,                      color: CardeoInk.secondary(context),                    ),                  ),                ],              ],            ),          ),          const SizedBox(width: AppSpacing.xs),          action.isLoading              ? const Padding(                  padding: EdgeInsets.symmetric(                    horizontal: 12,                    vertical: AppSpacing.sm,                  ),                  child: SizedBox.square(                    dimension: 18,                    child: AppProgressIndicator(strokeWidth: 2),                  ),                )              : TextButton.icon(                  style: TextButton.styleFrom(                    minimumSize: const Size(0, 44),                  ),                  onPressed: () => document.expectsFiles                      ? _chooseAndUploadEqualsMoneyDocument(                          context,                          ref,                          document,                        )                      : _answerEqualsMoneyInformation(context, ref, document),                  icon: Icon(                    document.expectsFiles                        ? Icons.upload_file                        : Icons.edit_note_outlined,                    size: 18,                  ),                  label: Text(document.expectsFiles                      ? context.tr('Upload')                      : context.tr('Answer')),                ),        ],      ),    );  }} /// The one next step, pinned above the safe area in a frosted bar. Renders/// nothing at all when the account is approved or the provider is reviewing:/// an empty bar is worse than no bar.class _StickyNextStep extends ConsumerWidget {  const _StickyNextStep({    required this.status,    required this.isBusinessAccount,    required this.onboarded,    required this.needsInput,    required this.inReview,  });   final KycDetailedStatus? status;  final bool isBusinessAccount;  final bool onboarded;  final bool needsInput;  final bool inReview;   @override  Widget build(BuildContext context, WidgetRef ref) {    final action = ref.watch(platformActionControllerProvider);    final busy = action.isLoading;    final current = status;     String label;    IconData icon;    VoidCallback? onPressed;    if (needsInput && current != null && current.hasEqualsMoneyActionUrl) {      label = _equalsMoneyActionLabel(current.equalsMoneyRequiredAction);      icon = Icons.open_in_new;      onPressed = busy          ? null          : () => _openEqualsMoneyActionUrl(                context,                ref,                fallbackUrl: current.equalsMoneyActionUrl!,              );    } else if (!onboarded && !needsInput && !inReview) {      label = isBusinessAccount          ? 'Start business onboarding'          : current == null              ? 'Identity status unavailable'              : !current.isHoppaApproved                  ? 'Continue identity verification'                  : 'Start EqualsMoney';      icon = Icons.arrow_forward_rounded;      onPressed = busy || (!isBusinessAccount && current == null)          ? null          : isBusinessAccount              ? () => context.go(AppRoutes.business)              : current?.isHoppaApproved != true                  ? () => context.go(AppRoutes.kyc)                  : () => _showEqualsMoneyDialog(context, ref);    } else {      return const SizedBox.shrink();    }     // The one decisive action of the onboarding flow, in the bar that is    // pinned to it. Ground is `surface`, not `atmosphere`: PlatformStickyBar    // already runs the screen's one BackdropFilter and paints a gradient over    // it, so from this button's point of view the ground is opaque. A second,    // nested blur would sample the first one's own output — an expensive way    // to make the bar look muddier.    return PlatformStickyBar(      child: CardeoGlassButton(        label: label,        icon: icon,        loading: busy,        onPressed: onPressed,      ),    );  }} /// The pre-Cardeo screen, kept byte-identical for every white-label tenant.class _LegacyBody extends ConsumerWidget {  const _LegacyBody();   @override  Widget build(BuildContext context, WidgetRef ref) {    final kycStatus = ref.watch(kycDetailedStatusProvider);    final dashboard = ref.watch(dashboardProvider);    final isBusinessAccount =        dashboard.valueOrNull?.profile.isBusinessAccount ?? false;    final equalsMoneyOnboarded = kycStatus.maybeWhen(      data: (status) => status.canUseEqualsMoneyBanking,      orElse: () => false,    );    final equalsMoneyNeedsInput =        kycStatus.valueOrNull?.requiresEqualsMoneyAction ?? false;    final equalsMoneyInReview =        kycStatus.valueOrNull?.hasEqualsMoneyReviewState ?? false;    final equalsMoneyAccountId = kycStatus.valueOrNull?.equalsMoneyAccountId;     return ListView(      padding: const EdgeInsets.all(16),      children: [        _OnboardingProgressCard(status: kycStatus),        if (equalsMoneyNeedsInput) ...[          const SizedBox(height: 16),          EqualsMoneyRequiredActionCard(status: kycStatus.valueOrNull!),        ],        const SizedBox(height: 16),        Text(          equalsMoneyNeedsInput              ? context.tr('Required now')              : context.tr('Next actions'),          style: Theme.of(context).textTheme.titleLarge,        ),        const SizedBox(height: 8),        _ActionTile(          enabled: !equalsMoneyOnboarded &&              !equalsMoneyNeedsInput &&              !equalsMoneyInReview &&              (isBusinessAccount || kycStatus.hasValue),          icon: equalsMoneyOnboarded              ? Icons.check_circle_outline              : equalsMoneyNeedsInput || equalsMoneyInReview                  ? Icons.hourglass_top                  : Icons.account_balance,          title: equalsMoneyOnboarded              ? context.tr('EqualsMoney active')              : equalsMoneyNeedsInput                  ? context.tr('EqualsMoney waiting for you')                  : equalsMoneyInReview                      ? context.tr('EqualsMoney in review')                      : isBusinessAccount                          ? context.tr('Start EqualsMoney business')                          : kycStatus.valueOrNull?.isHoppaApproved == true                              ? context.tr('Start EqualsMoney')                              : context.tr('Continue identity verification'),          subtitle: equalsMoneyOnboarded              ? context.tr('Account {p0} is ready',                  {'p0': equalsMoneyAccountId ?? 'connected'})              : equalsMoneyNeedsInput                  ? context.tr(                      'Upload documents or complete the secure provider check')                  : equalsMoneyInReview                      ? context.tr(                          'We will update this screen when the provider approves it')                      : isBusinessAccount                          ? context.tr('Open EU/UK business account')                          : kycStatus.valueOrNull?.isHoppaApproved == true                              ? context.tr('Open banking setup')                              : context.tr(                                  'Identity verification must be approved first'),          onTap: equalsMoneyOnboarded ||                  equalsMoneyNeedsInput ||                  equalsMoneyInReview              ? null              : isBusinessAccount                  ? (context, ref) => context.go(AppRoutes.business)                  : kycStatus.valueOrNull?.isHoppaApproved == true                      ? (context, ref) => _showEqualsMoneyDialog(context, ref)                      : (context, ref) => context.go(AppRoutes.kyc),        ),        if (!equalsMoneyNeedsInput) ...[          _ActionTile(            enabled: equalsMoneyOnboarded,            icon: Icons.info_outline,            title: context.tr('Banking info'),            subtitle: equalsMoneyOnboarded                ? context.tr('View EqualsMoney account details')                : context.tr('Available after EqualsMoney approval'),            onTap: equalsMoneyOnboarded                ? (context, ref) =>                    ref.read(platformActionControllerProvider.notifier).run(                      (api) async {                        final info = await api.getEqualsBankingInfo();                         return ActionResult(                          message: info.title,                          reference: info.id,                          metadata: info.metadata,                        );                      },                    )                : null,          ),          _ActionTile(            enabled: equalsMoneyOnboarded,            icon: Icons.savings_outlined,            title: context.tr('Create EUR budget'),            subtitle: equalsMoneyOnboarded                ? context.tr('Set a monthly spending pool')                : context.tr('Available after EqualsMoney approval'),            onTap: equalsMoneyOnboarded                ? (context, ref) => ref                    .read(platformActionControllerProvider.notifier)                    .run((api) => api.createBudget(                          name: 'New budget',                          currency: 'EUR',                        ))                : null,          ),          _ActionTile(            enabled: equalsMoneyOnboarded,            icon: Icons.edit_outlined,            title: context.tr('Update budget'),            subtitle: equalsMoneyOnboarded                ? context.tr('Adjust budget settings')                : context.tr('Available after EqualsMoney approval'),            onTap: equalsMoneyOnboarded                ? (context, ref) =>                    ref.read(platformActionControllerProvider.notifier).run(                          (api) => api.updateBudget(                            budgetId: 'budget_ops',                            name: 'Operations',                            currency: 'EUR',                          ),                        )                : null,          ),          _ActionTile(            enabled: equalsMoneyOnboarded,            icon: Icons.compare_arrows,            title: context.tr('Move budget funds'),            subtitle: equalsMoneyOnboarded                ? context.tr('Transfer between budgets')                : context.tr('Available after EqualsMoney approval'),            onTap: equalsMoneyOnboarded                ? (context, ref) =>                    ref.read(platformActionControllerProvider.notifier).run(                          (api) => api.transferBudget(                            fromBudgetId: 'budget_ops',                            toBudgetId: 'budget_cards',                            amount: const Money(                              currency: 'EUR',                              minorUnits: 2500,                            ),                          ),                        )                : null,          ),          const SizedBox(height: 24),          Text(context.tr('Providers'),              style: Theme.of(context).textTheme.titleLarge),          const SizedBox(height: 8),          ref.watch(providersProvider).when(                data: (items) => ResourceList(                  resources: equalsMoneyOnboarded ? items : const [],                  emptyTitle: equalsMoneyOnboarded                      ? context.tr('No providers connected')                      : context.tr('Provider details locked'),                  emptyMessage: equalsMoneyOnboarded                      ? context.tr('Provider details will appear after sync.')                      : context.tr(                          'Complete EqualsMoney approval before account details are shown.'),                  icon: Icons.account_balance,                ),                error: (error, stackTrace) => ErrorState(                  error: error,                  onRetry: () => ref.invalidate(providersProvider),                ),                loading: () =>                    LoadingState(label: context.tr('Loading providers')),              ),          const SizedBox(height: 24),          Text(context.tr('Budgets'),              style: Theme.of(context).textTheme.titleLarge),          const SizedBox(height: 8),          ref.watch(budgetsProvider).when(                data: (items) => ResourceList(                  resources: equalsMoneyOnboarded ? items : const [],                  emptyTitle: equalsMoneyOnboarded                      ? context.tr('No budgets yet')                      : context.tr('Budgets locked'),                  emptyMessage: equalsMoneyOnboarded                      ? context.tr('Create a budget to organize spending.')                      : context                          .tr('Budgets unlock after EqualsMoney approval.'),                  icon: Icons.savings_outlined,                ),                error: (error, stackTrace) => ErrorState(                  error: error,                  onRetry: () => ref.invalidate(budgetsProvider),                ),                loading: () =>                    LoadingState(label: context.tr('Loading budgets')),              ),        ],      ],    );  }} class _OnboardingProgressCard extends StatelessWidget {  const _OnboardingProgressCard({required this.status});   final AsyncValue<KycDetailedStatus> status;   @override  Widget build(BuildContext context) {    final detailedStatus = status.valueOrNull;    final completed = detailedStatus?.completedSetupStepCount;    final total = detailedStatus?.setupStepCount ?? 3;    final progress = completed == null ? null : completed / total;    final stepLabel = completed == null ? '...' : '$completed/$total';     return Card(      child: Padding(        padding: const EdgeInsets.all(16),        child: Column(          crossAxisAlignment: CrossAxisAlignment.start,          children: [            Row(              children: [                const Icon(Icons.check_circle_outline),                const SizedBox(width: 12),                Expanded(                  child: Text(                    context.tr('Banking setup'),                    style: Theme.of(context).textTheme.titleMedium,                  ),                ),                Text(                  stepLabel,                  style: Theme.of(context).textTheme.labelLarge,                ),              ],            ),            const SizedBox(height: 12),            LinearProgressIndicator(value: progress),            const SizedBox(height: 12),            Wrap(              spacing: 8,              runSpacing: 8,              children: [                _SetupChip(                  label: context.tr('Identity verification'),                  isComplete: detailedStatus?.isHoppaApproved,                ),                _SetupChip(                  label: context.tr('Bank KYC'),                  isComplete: detailedStatus?.isBankApproved,                ),                _SetupChip(                  label: context.tr('Card issuer'),                  isComplete: detailedStatus?.isCardIssuerApproved,                ),              ],            ),            if (detailedStatus?.hasEqualsMoneyAccount ?? false) ...[              const SizedBox(height: 8),              Text(                context.tr('EqualsMoney account {p0}',                    {'p0': detailedStatus!.equalsMoneyAccountId}),                style: Theme.of(context).textTheme.bodySmall,              ),            ],          ],        ),      ),    );  }} class EqualsMoneyRequiredActionCard extends ConsumerWidget {  const EqualsMoneyRequiredActionCard({required this.status, super.key});   final KycDetailedStatus status;   @override  Widget build(BuildContext context, WidgetRef ref) {    final theme = Theme.of(context);    final action = ref.watch(platformActionControllerProvider);    final documents = status.equalsMoneyAdditionalDocumentsRequested;    final hasActionUrl = status.hasEqualsMoneyActionUrl;    final requiredAction = status.equalsMoneyRequiredAction;     return Card(      child: Padding(        padding: const EdgeInsets.all(16),        child: Column(          crossAxisAlignment: CrossAxisAlignment.start,          children: [            Row(              crossAxisAlignment: CrossAxisAlignment.start,              children: [                Icon(                  Icons.assignment_late_outlined,                  color: theme.colorScheme.primary,                ),                const SizedBox(width: 12),                Expanded(                  child: Column(                    crossAxisAlignment: CrossAxisAlignment.start,                    children: [                      Text(                        context.tr('EqualsMoney needs your input'),                        style: theme.textTheme.titleMedium,                      ),                      const SizedBox(height: 4),                      Text(                        context.tr(_equalsMoneyActionMessage(                          hasDocuments: documents.isNotEmpty,                          hasActionUrl: hasActionUrl,                          requiredAction: requiredAction,                        )),                      ),                    ],                  ),                ),              ],            ),            if (documents.isNotEmpty) ...[              const SizedBox(height: 16),              for (final document in documents) ...[                _EqualsMoneyDocumentTile(document: document),                const SizedBox(height: 8),              ],            ],            if (!hasActionUrl &&                _isIdentityVerificationAction(requiredAction)) ...[              const SizedBox(height: 8),              TextButton.icon(                onPressed: () => ref.invalidate(kycDetailedStatusProvider),                icon: const Icon(Icons.refresh),                label: Text(context.tr('Refresh status')),              ),            ],            if (hasActionUrl) ...[              const SizedBox(height: 8),              // This card is NOT onboarding-only: `AccountsContent` and the              // money surface both render it as the entire body when the              // provider is blocking, so on Cardeo this button was the whole              // screen's decisive action still wearing the Material slab.              // Gated on the theme, because the same card is the white-label              // onboarding body too and that tree must not move.              //              // `surface` ground — a Material Card paints an opaque floor and              // there is nothing behind it worth blurring. `loading` carries              // the action controller's busy state in place, so the label              // does not vanish mid-redirect.              if (context.isCardeoTheme)                CardeoGlassButton(                  label: context.tr(_equalsMoneyActionLabel(requiredAction)),                  icon: Icons.open_in_new,                  loading: action.isLoading,                  onPressed: action.isLoading                      ? null                      : () => _openEqualsMoneyActionUrl(                            context,                            ref,                            fallbackUrl: status.equalsMoneyActionUrl!,                          ),                )              else                FilledButton.icon(                  onPressed: action.isLoading                      ? null                      : () => _openEqualsMoneyActionUrl(                            context,                            ref,                            fallbackUrl: status.equalsMoneyActionUrl!,                          ),                  icon: const Icon(Icons.open_in_new),                  label: Text(                    context.tr(_equalsMoneyActionLabel(requiredAction)),                  ),                ),            ],          ],        ),      ),    );  }} class _EqualsMoneyDocumentTile extends ConsumerWidget {  const _EqualsMoneyDocumentTile({required this.document});   final EqualsMoneyAdditionalDocumentRequest document;   @override  Widget build(BuildContext context, WidgetRef ref) {    final action = ref.watch(platformActionControllerProvider);     return DecoratedBox(      decoration: BoxDecoration(        border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),        borderRadius: BorderRadius.circular(12),      ),      child: ListTile(        contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),        leading: const Icon(Icons.description_outlined),        title: Text(_equalsMoneyRequestLabel(document.type, document.text)),        subtitle: document.expectsFiles            ? Text(                [                  document.type,                  if (document.associatedPersonName != null)                    document.associatedPersonName!,                  if (document.associatedPersonEmail != null)                    document.associatedPersonEmail!,                  if (document.additionalInformation != null)                    document.additionalInformation!,                ].join('\n'),              )            : null,        trailing: action.isLoading            ? const SizedBox.square(                dimension: 18,                child: AppProgressIndicator(strokeWidth: 2),              )            : TextButton.icon(                onPressed: () => document.expectsFiles                    ? _chooseAndUploadEqualsMoneyDocument(                        context,                        ref,                        document,                      )                    : _answerEqualsMoneyInformation(context, ref, document),                icon: Icon(                  document.expectsFiles                      ? Icons.upload_file                      : Icons.edit_note_outlined,                ),                label: Text(document.expectsFiles                    ? context.tr('Upload')                    : context.tr('Answer')),              ),      ),    );  }} class _SetupChip extends StatelessWidget {  const _SetupChip({    required this.label,    required this.isComplete,  });   final String label;  final bool? isComplete;   @override  Widget build(BuildContext context) {    return Chip(      avatar: Icon(        isComplete == true ? Icons.check_circle : Icons.radio_button_unchecked,        size: 18,      ),      label: Text(label),    );  }} class _ActionTile extends ConsumerWidget {  const _ActionTile({    this.enabled = true,    required this.icon,    required this.title,    required this.subtitle,    required this.onTap,  });   final bool enabled;  final IconData icon;  final String title;  final String subtitle;  final void Function(BuildContext context, WidgetRef ref)? onTap;   @override  Widget build(BuildContext context, WidgetRef ref) {    final action = ref.watch(platformActionControllerProvider);     return Card(      child: ListTile(        enabled: enabled && !action.isLoading,        leading: Icon(icon),        title: Text(title),        subtitle: Text(subtitle),        trailing: action.isLoading            ? const SizedBox.square(                dimension: 18,                child: AppProgressIndicator(strokeWidth: 2),              )            : const Icon(Icons.chevron_right),        onTap: enabled && !action.isLoading && onTap != null            ? () => onTap!(context, ref)            : null,      ),    );  }} /// Whether the customer is on a tier. Null when the lookup failed: an unknown/// answer is not "no tier", so the application is not blocked on it.Future<bool?> _hasSelectedTier(WidgetRef ref) async {  try {    final current = await ref.read(mobilePlatformApiProvider).getCurrentTier();    return tierIdOf(current) != null;  } catch (_) {    return null;  }} /// Opening an account needs a tier: say so, and lead straight to the tiers.Future<void> _showTierRequiredNotice(BuildContext context) {  return showCardeoSheet<void>(    context,    builder: (sheetContext) => Column(      mainAxisSize: MainAxisSize.min,      crossAxisAlignment: CrossAxisAlignment.stretch,      children: [        CardeoSheetHeader(title: sheetContext.tr('Choose a tier first')),        const SizedBox(height: AppSpacing.xs),        const CardeoSheetNote(            'Your account can only be opened on a tier. Choose one, then come back here to open your account.'),        const SizedBox(height: AppSpacing.md),        CardeoSheetCta(          label: sheetContext.tr('Choose a tier'),          onPressed: () {            Navigator.of(sheetContext).pop();            context.push(AppRoutes.tiers);          },        ),        const SizedBox(height: AppSpacing.xs),        CardeoGlassButton(          label: sheetContext.tr('Not now'),          tone: CardeoGlassButtonTone.neutral,          ground: CardeoGlassGround.surface,          sheen: false,          onPressed: () => Navigator.of(sheetContext).pop(),        ),      ],    ),  );} Future<void> _showEqualsMoneyDialog(BuildContext context, WidgetRef ref) async {  // Other screens share this entry point: never open legacy PoA collection for CDD.  try {    if (await ref.read(unifiedCddPreferenceProvider.future)) {      if (context.mounted) context.go(AppRoutes.kyc);      return;    }  } catch (_) {    if (context.mounted) {      ScaffoldMessenger.of(context).showSnackBar(SnackBar(        content: Text(context.tr('Could not load onboarding. Please retry.'))));    }    return;  }  // Refresh at the entry point as the visible status may be stale.  try {    final status =        await ref.read(mobilePlatformApiProvider).getDetailedKycStatus();    if (!context.mounted) return;    ref.invalidate(kycDetailedStatusProvider);    if (!status.isHoppaApproved) {      context.go(AppRoutes.kyc);      return;    }  } catch (_) {    if (!context.mounted) return;    ScaffoldMessenger.of(context).showSnackBar(SnackBar(      content: Text(          context.tr('Unable to verify identity status. Please try again.')),    ));    return;  }  // The provider refuses an account application without a tier, and says so  // only after the whole form has been filled in. Account setup lets the  // tier step be skipped, so ask for it here, before the form.  if (await _hasSelectedTier(ref) == false) {    if (context.mounted) await _showTierRequiredNotice(context);    return;  }  if (!context.mounted) return;  final formKey = GlobalKey<FormState>();  var includeCards = true;  var mainPurposes = ['PURCHASE_OF_GOODS_OR_SERVICES'];  var sourceOfFunds = ['RECEIVING_FUNDS_FROM_OWN_ACCOUNTS'];  var destinationCountries = ['GB'];  var currencies = ['GBP'];  var annualVolume = '10001-50000';  var numberOfPayments = '5-10';  var cardPurposes = ['PURCHASE_OF_GOODS_OR_SERVICES'];  var cardAnnualSpend = '0-10000';  var numberOfCardsRequired = '0-10';  var atmWithdrawalsRequired = false;  UploadDocument? proofOfAddress;  String? uploadError;   final confirmed = await showDialog<bool>(    context: context,    builder: (context) => StatefulBuilder(      builder: (context, setState) => AlertDialog(        title: Text(context.tr('EqualsMoney onboarding')),        content: SingleChildScrollView(          child: Form(            key: formKey,            child: Column(              mainAxisSize: MainAxisSize.min,              children: [                SearchableMultiSelectDropdown(                  label: context.tr('Main purposes'),                  values: mainPurposes,                  options: equalsPersonalPurposeOptions,                  onChanged: (value) => setState(() => mainPurposes = value),                ),                const SizedBox(height: 12),                SearchableMultiSelectDropdown(                  label: context.tr('Sources of funds'),                  values: sourceOfFunds,                  options: equalsPersonalSourceOfFundsOptions,                  onChanged: (value) => setState(() => sourceOfFunds = value),                ),                const SizedBox(height: 12),                SearchableMultiSelectDropdown(                  label: context.tr('Destination countries'),                  values: destinationCountries,                  options: equalsCountryCodes,                  labelBuilder: (v) => personalOnboardingCountryLabel(context, v),                  onChanged: (value) =>                      setState(() => destinationCountries = value),                ),                const SizedBox(height: 12),                SearchableMultiSelectDropdown(                  label: context.tr('Required currencies'),                  values: currencies,                  options: equalsSupportedCurrencyCodes,                  onChanged: (value) => setState(() => currencies = value),                ),                const SizedBox(height: 12),                _RequiredDropdown(                  label: context.tr('Annual volume'),                  value: annualVolume,                  options: equalsAnnualVolumeOptions,                  onChanged: (value) => setState(() => annualVolume = value),                ),                const SizedBox(height: 12),                _RequiredDropdown(                  label: context.tr('Number of payments'),                  value: numberOfPayments,                  options: equalsPersonalPaymentCountOptions,                  onChanged: (value) =>                      setState(() => numberOfPayments = value),                ),                const SizedBox(height: 12),                SwitchListTile(                  contentPadding: EdgeInsets.zero,                  title: Text(context.tr('Request cards')),                  value: includeCards,                  onChanged: (value) => setState(() => includeCards = value),                ),                if (includeCards) ...[                  SearchableMultiSelectDropdown(                    label: context.tr('Card purposes'),                    values: cardPurposes,                    options: equalsPersonalPurposeOptions,                    onChanged: (value) => setState(() => cardPurposes = value),                  ),                  const SizedBox(height: 12),                  _RequiredDropdown(                    label: context.tr('Annual card spend'),                    value: cardAnnualSpend,                    options: equalsAnnualVolumeOptions,                    onChanged: (value) =>                        setState(() => cardAnnualSpend = value),                  ),                  const SizedBox(height: 12),                  _RequiredDropdown(                    label: context.tr('Number of cards required'),                    value: numberOfCardsRequired,                    options: equalsCardCountOptions,                    onChanged: (value) =>                        setState(() => numberOfCardsRequired = value),                  ),                  const SizedBox(height: 12),                  SwitchListTile(                    contentPadding: EdgeInsets.zero,                    title: Text(context.tr('ATM withdrawals required')),                    value: atmWithdrawalsRequired,                    onChanged: (value) =>                        setState(() => atmWithdrawalsRequired = value),                  ),                ],                const SizedBox(height: 12),                ListTile(                  contentPadding: EdgeInsets.zero,                  leading: const Icon(Icons.upload_file),                  title: Text(                    proofOfAddress?.name ?? 'Proof of address',                  ),                  subtitle: Text(                      context.tr('PDF, JPG, JPEG, or PNG. Maximum 10 MB.')),                  trailing: TextButton(                    onPressed: () async {                      try {                        final result = await FilePicker.platform.pickFiles(                          type: FileType.custom,                          allowedExtensions: ['pdf', 'jpg', 'jpeg', 'png'],                          withData: true,                        );                        if (result == null || !context.mounted) return;                        final document = UploadDocument.fromPlatformFile(                            result.files.single);                        setState(() {                          proofOfAddress = document;                          uploadError = null;                        });                      } catch (error) {                        if (!context.mounted) return;                        setState(() => uploadError = error is FormatException                            ? error.message                            : 'The file could not be opened. Please choose it again.');                      }                    },                    child: Text(context.tr('Choose')),                  ),                ),                if (proofOfAddress == null || uploadError != null)                  Align(                    alignment: Alignment.centerLeft,                    child: Text(                      uploadError ?? 'Proof of address is required.',                      style: TextStyle(                        color: Theme.of(context).colorScheme.error,                        fontSize: 12,                      ),                    ),                  ),              ],            ),          ),        ),        actions: [          TextButton(            onPressed: () => Navigator.of(context).pop(false),            child: Text(context.tr('Cancel')),          ),          FilledButton(            onPressed: () {              final hasSelections = mainPurposes.isNotEmpty &&                  sourceOfFunds.isNotEmpty &&                  destinationCountries.isNotEmpty &&                  currencies.isNotEmpty &&                  (!includeCards || cardPurposes.isNotEmpty);              if (!(formKey.currentState?.validate() ?? false) ||                  !hasSelections ||                  proofOfAddress == null) {                return;              }              Navigator.of(context).pop(true);            },            child: Text(context.tr('Submit')),          ),        ],      ),    ),  );   if (!context.mounted || confirmed != true || proofOfAddress == null) {    return;  }   await ref.read(platformActionControllerProvider.notifier).run(        (api) => api.startEqualsMoneyOnboarding(          requestedFeatures:              includeCards ? ['PAYMENTS', 'CARDS'] : ['PAYMENTS'],          mainPurpose: mainPurposes,          sourceOfFunds: sourceOfFunds,          destinationOfFunds: destinationCountries,          currenciesRequired: currencies,          annualVolume: annualVolume,          numberOfPayments: numberOfPayments,          cardPurposes: includeCards ? cardPurposes : const [],          cardAnnualSpend: includeCards ? cardAnnualSpend : null,          numberOfCardsRequired: includeCards ? numberOfCardsRequired : null,          atmWithdrawalsRequired: atmWithdrawalsRequired,          proofOfAddress: proofOfAddress,        ),      );} Future<void> _chooseAndUploadEqualsMoneyDocument(  BuildContext context,  WidgetRef ref,  EqualsMoneyAdditionalDocumentRequest document,) async {  try {    final result = await FilePicker.platform.pickFiles(      type: FileType.custom,      allowedExtensions: ['pdf', 'jpg', 'jpeg', 'png'],      allowMultiple: true,      withData: true,    );    if (result == null || !context.mounted) return;    if (result.files.length > 10) {      throw const FormatException('Choose at most 10 documents at a time.');    }    final documents =        result.files.map(UploadDocument.fromPlatformFile).toList();    if (documents.isEmpty) return;    await ref.read(platformActionControllerProvider.notifier).run(          (api) => api.uploadEqualsMoneyDocument(            type: document.type,            documents: documents,            applicationId: document.applicationId,            associatedPersonId: document.associatedPersonId,          ),        );  } catch (error) {    if (!context.mounted) return;    ScaffoldMessenger.of(context).showSnackBar(SnackBar(      content: Text(error is FormatException          ? error.message          : context              .tr('The files could not be opened. Please choose them again.')),    ));  }} Future<void> _answerEqualsMoneyInformation(  BuildContext context,  WidgetRef ref,  EqualsMoneyAdditionalDocumentRequest request,) async {  final answer = await showAdditionalInformationDialog(    context,    title: _equalsMoneyRequestLabel(request.type, request.text),  );  if (answer == null || !context.mounted) {    return;  }   await ref.read(platformActionControllerProvider.notifier).run(        (api) => api.submitEqualsMoneyInformation(          type: request.type,          response: answer,          associatedPersonId: request.associatedPersonId,        ),      );} String _equalsMoneyRequestLabel(String type, String fallback) {  return type.trim().toUpperCase() == 'RESIDENTIAL_ADDRESS'      ? 'Enter your residential address'      : fallback;} Future<void> _openEqualsMoneyActionUrl(  BuildContext context,  WidgetRef ref, {  required String fallbackUrl,}) async {  var url = fallbackUrl;  try {    final freshStatus =        await ref.read(mobilePlatformApiProvider).getDetailedKycStatus();    final freshUrl = freshStatus.equalsMoneyActionUrl;    if (freshUrl != null && freshUrl.trim().isNotEmpty) {      url = freshUrl.trim();    }    ref.invalidate(kycDetailedStatusProvider);  } catch (_) {    // Keep the currently displayed URL as a fallback if refresh is unavailable.  }   final uri = Uri.tryParse(url);  if (uri == null) {    if (context.mounted) {      ScaffoldMessenger.of(context).showSnackBar(        SnackBar(            content: Text(context.tr('EqualsMoney check link is invalid.'))),      );    }    return;  }   final opened = await launchUrl(    uri,    mode: LaunchMode.externalApplication,  );  if (!opened && context.mounted) {    ScaffoldMessenger.of(context).showSnackBar(      SnackBar(content: Text(context.tr('Could not open EqualsMoney check.'))),    );  }} String _equalsMoneyActionMessage({  required bool hasDocuments,  required bool hasActionUrl,  required String? requiredAction,}) {  if (hasDocuments && hasActionUrl) {    return 'Upload the requested documents here, then continue the secure EqualsMoney check.';  }  if (hasDocuments) {    return 'Upload each requested document so EqualsMoney can continue the review.';  }  if (!hasActionUrl && _isIdentityVerificationAction(requiredAction)) {    return 'EqualsMoney requires identity verification, but the verification link is currently unavailable. Refresh the status or contact support.';  }  if (requiredAction?.trim().toLowerCase() == 'identityverificationcheck') {    return 'Complete the secure EqualsMoney identity verification before the account can be approved.';  }  if (_isLivenessAction(requiredAction)) {    return 'EqualsMoney needs a liveness check before the account can be approved.';  }   return 'EqualsMoney needs an additional check before the account can be approved.';} String _equalsMoneyActionLabel(String? action) {  if (action?.trim().toLowerCase() == 'identityverificationcheck') {    return 'Complete identity verification';  }  return _isLivenessAction(action)      ? 'Continue liveness check'      : 'Continue EqualsMoney check';} bool _isIdentityVerificationAction(String? action) =>    action?.trim().toLowerCase() == 'identityverificationcheck' ||    _isLivenessAction(action); bool _isLivenessAction(String? action) {  final normalized = action?.toLowerCase().trim() ?? '';  return normalized.contains('liveness') || normalized.contains('liveless');} class _RequiredDropdown extends StatelessWidget {  const _RequiredDropdown({    required this.label,    required this.value,    required this.options,    required this.onChanged,  });   final String label;  final String value;  final List<String> options;  final ValueChanged<String> onChanged;   @override  Widget build(BuildContext context) {    return DropdownButtonFormField<String>(      initialValue: value,      isExpanded: true,      decoration: InputDecoration(labelText: label),      items: [        for (final option in options)          DropdownMenuItem(            value: option,            child: Text(              option.replaceAll('_', ' '),              overflow: TextOverflow.ellipsis,            ),          ),      ],      validator: (value) => value == null ? '$label is required' : null,      onChanged: (value) {        if (value != null) {          onChanged(value);        }      },    );  }}
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.