paycross_flutter 0.7.4 copy "paycross_flutter: ^0.7.4" to clipboard
paycross_flutter: ^0.7.4 copied to clipboard

PayCross payment SDK for Flutter: card payments, 3-D Secure v2, saved cards, Google Pay on Android and Apple Pay on iOS, wrapping the native Android and iOS SDKs.

example/lib/main.dart

import 'package:flutter/material.dart';
import 'package:paycross_flutter/paycross_flutter.dart';

import 'automation_screen.dart';
import 'demo/appearance_store.dart';
import 'demo/deeplink.dart';
import 'demo/environment.dart';
import 'demo/home.dart';
import 'demo/language.dart';
import 'demo/minter.dart';
import 'demo/presets.dart';
import 'demo/secrets.dart';
import 'demo/wallets.dart';
import 'e2e_mode.dart';

/// Google Pay merchant id, passed straight to `PayCross.configure`.
///
/// Empty means "not supplied", which is the merchant-facing default and is
/// exactly what the app did before this define existed.
const String _googlePayMerchantId = String.fromEnvironment(
  'PAYCROSS_E2E_GOOGLE_PAY_MERCHANT_ID',
);

/// The language the payment sheet is pinned to, as a BCP 47 tag.
///
/// Read only by the automation build, where there is no Settings screen to
/// choose one on: it is how a matrix cell asks for a French sheet. Empty
/// means "not supplied", which is the native ladder's first rung left empty
/// rather than a request for English.
///
/// The demo build ignores this and reads the choice a colleague made in
/// Settings instead.
const String _locale = String.fromEnvironment('PAYCROSS_LOCALE');

/// How the payment sheet should look, as the compact JSON `DemoAppearance`
/// reads: `{"brandLight":"00875A","brandDark":"57D9A3","themeMode":"dark",
/// "cornerRadius":16,"buttonCornerRadius":28,"fontScale":1.2}`. Every field is
/// optional and an absent one changes nothing.
///
/// Read only by the automation build, where there is no Settings screen to
/// theme the sheet on: it is how a matrix cell asks for a green sheet with
/// round corners. Empty means "not supplied", which is the sheet as it comes.
///
/// A string this build cannot read costs the cell its theme and says so on
/// the log, rather than costing it the launch -- which is why it is decoded
/// through `DemoAppearance.fromJson` rather than by anything that throws.
/// Before this existed the only way to run a themed cell was to edit this
/// file by hand for the duration of a smoke.
///
/// The demo build ignores this and reads what a colleague set in Settings.
const String _appearance = String.fromEnvironment('PAYCROSS_APPEARANCE');

/// The secure store `main` reads the saved merchant id from.
///
/// A variable rather than a parameter: `main` is the entrypoint and cannot
/// take one, and the read has to happen inside the compile-time conditional
/// below rather than above it. `PayCross.debugHostApi` is the same shape for
/// the same reason -- a seam a test replaces, and nothing else touches.
@visibleForTesting
SecretStore mainSecretStore = const SecretStore();

/// The preference store `main` reads the chosen sheet language from.
///
/// A variable for the same reason [mainSecretStore] is one, and read in the
/// same guarded arm.
@visibleForTesting
LanguageStore mainLanguageStore = const LanguageStore();

/// The preference store `main` reads the chosen appearance from.
///
/// A variable for the same reason [mainLanguageStore] is one, and read in the
/// same guarded arm.
@visibleForTesting
AppearanceStore mainAppearanceStore = const AppearanceStore();

/// How long either launch read gets before the app starts without it.
///
/// All three reads block the first frame, and all three cross a platform
/// channel that can go quiet rather than throw — which is the failure
/// `preset_store.dart` and `history.dart` bound their own writes against. Five
/// seconds is the bound they chose, and one number for every read here so that
/// a store which stalls costs the same wherever it is.
const Duration _launchReadTimeout = Duration(seconds: 5);

/// Runs a real payment against sandbox with no backend of your own.
///
/// Under `--dart-define=PAYCROSS_E2E=true` this awaits exactly one thing and
/// cannot fail: no stored credentials are read, no deep-link subscription is
/// opened, and the app goes straight to the automation screen. An unguarded
/// await here would take down all six D0 cells on both platforms, and the
/// failure would look like an SDK hang.
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  // Under the define this whole expression is the constant branch -- no
  // storage read, no extra await -- so the frozen build still awaits exactly
  // one thing and still cannot fail before runApp.
  final merchantId = kE2e
      ? (_googlePayMerchantId.isEmpty ? null : _googlePayMerchantId)
      : await _storedGooglePayMerchantId();
  // The same shape, and behind the same conditional: under the define this is
  // the constant branch too, so the frozen build still awaits exactly one
  // thing -- the configure call below.
  final locale = kE2e
      ? (_locale.isEmpty ? null : _locale)
      : await _storedLocale();
  // The same shape again. The automation arm decodes a build constant, which
  // costs no await and cannot throw, so the frozen build's budget is unmoved.
  final appearance = kE2e
      ? DemoAppearance.fromJson(_appearance).toAppearance()
      : await _storedAppearance();
  // Awaited so a fast first tap on Pay cannot race the configure call.
  await PayCross.configure(
    environment: PayCrossEnvironment.sandbox,
    googlePayMerchantId: merchantId,
    // A build constant rather than a stored value, and not read from
    // anywhere: it has to be the string in the app's entitlement, and
    // PassKit refuses -- silently, with the button still on screen -- to
    // present a sheet for an identifier the entitlement does not list. It
    // costs no await, so the frozen automation build below is unaffected.
    applePayMerchantId: testApplePayMerchantId,
    // Passed through untouched. Both native SDKs own the ladder this is the
    // first rung of, and both skip a tag they cannot read rather than
    // throwing, so a typo in the define costs a cell nothing.
    locale: locale,
    // Passed through untouched, like the locale. Nothing here can be refused
    // by the call: every way an appearance is built in this app drops the
    // numbers `configure` would raise on, which matters because this await is
    // not caught and there is no frame yet to report it on.
    appearance: appearance,
  );
  runApp(
    ExampleApp(
      googlePayMerchantId: merchantId,
      applePayMerchantId: testApplePayMerchantId,
      locale: locale,
      appearance: appearance,
    ),
  );
}

/// The Google Pay merchant id a colleague saved in Settings, or null.
///
/// `PayCross.configure` is called once per launch, so this is read here and
/// nowhere else -- which is why Settings tells the reader that a change takes
/// effect next launch. Guarded twice over: `SecretStore.read` already answers
/// null on any failure, and this catches anything it could still throw,
/// because an exception here would kill the app before `runApp`.
Future<String?> _storedGooglePayMerchantId() async {
  try {
    return (await mainSecretStore.read().timeout(
      _launchReadTimeout,
    ))?.googlePayMerchantId;
  } catch (_) {
    return null;
  }
}

/// The sheet language a colleague chose in Settings, as a tag, or null.
///
/// Read here and nowhere else, which is why Settings tells the reader that a
/// change takes effect next launch. Not wrapped in a `try` of its own,
/// unlike the merchant id above: `LanguageStore.read` already answers
/// [DemoLanguage.system] on any failure, and it is the guard because that is
/// the one place that knows an unreadable store and an unset one mean the
/// same thing here.
///
/// The bound is here rather than in the store, because it is this caller that
/// cannot afford silence: the Settings screen reads the same store and only
/// leaves its toggle disabled, and a timer armed on every mount of that
/// screen would outlive every widget test that opens it.
Future<String?> _storedLocale() async =>
    (await mainLanguageStore.read().timeout(
      _launchReadTimeout,
      onTimeout: () => DemoLanguage.system,
    )).tag;

/// The sheet theme a colleague set in Settings, or null.
///
/// Read here and nowhere else, which is why Settings tells the reader that a
/// change takes effect next launch. Bounded here rather than in the store, and
/// unguarded beyond that, for the two reasons [_storedLocale] gives:
/// `AppearanceStore.read` already answers `DemoAppearance.none` for anything
/// it cannot read, and it is this caller rather than the Settings screen that
/// cannot afford silence.
Future<PayCrossAppearance?> _storedAppearance() async =>
    (await mainAppearanceStore.read().timeout(
      _launchReadTimeout,
      onTimeout: () => DemoAppearance.none,
    )).toAppearance();

class ExampleApp extends StatelessWidget {
  const ExampleApp({
    super.key,
    this.googlePayMerchantId,
    this.applePayMerchantId,
    this.locale,
    this.appearance,
  });

  /// What `configure` was given at launch, carried down so that returning
  /// from Live to Test restores it rather than clearing it.
  final String? googlePayMerchantId;

  /// The same, for Apple Pay. Carried rather than read from the constant at
  /// the far end so that both wallets travel the one path.
  final String? applePayMerchantId;

  /// The sheet language `configure` was given at launch, carried down for the
  /// reason the wallet identifiers are: re-pointing the SDK replaces the whole
  /// configuration, so every later call has to send it again.
  final String? locale;

  /// The sheet theme `configure` was given at launch, carried down for the
  /// same reason: without it the first themed preset run would end by clearing
  /// the colours a colleague set in Settings, and they would stay cleared
  /// until the app was relaunched.
  final PayCrossAppearance? appearance;

  @override
  Widget build(BuildContext context) => MaterialApp(
    // The automation build keeps the old title: it is the Android recents
    // label, and the frozen build should look to a runner exactly as it did
    // before. It reaches no accessibility tree either way.
    title: kE2e ? 'PayCross Example' : 'PayCross Demo',
    theme: ThemeData(colorSchemeSeed: Colors.indigo),
    darkTheme: ThemeData.dark(useMaterial3: true),
    // Wraps the Navigator, so every pushed route reads one environment and
    // sits under one banner. Null under the define: the frozen build has no
    // environment toggle in it at all, which is a stronger statement than
    // having one that is switched off.
    builder: kE2e
        ? null
        : (context, child) => LiveModeScope(
            googlePayMerchantId: googlePayMerchantId,
            applePayMerchantId: applePayMerchantId,
            locale: locale,
            appearance: appearance,
            child: child!,
          ),
    home: kE2e ? const CheckoutScreen() : const DemoHome(),
  );
}

/// Home, wrapped in the deep-link subscription.
///
/// Separate from [ExampleApp] so the subscription is opened under a
/// `Navigator` and a `ScaffoldMessenger` -- a rejected link has somewhere to
/// report itself, and a run link has somewhere to push to.
///
/// Reached only from the demo branch above, so the automation build registers
/// no deep-link handler at all rather than one that is switched off.
class DemoHome extends StatefulWidget {
  const DemoHome({
    super.key,
    this.links,
    this.store = const SecretStore(),
    this.mintWith = mintWithCredentials,
  });

  /// Injected by tests. Null means the real platform stream.
  final Stream<Uri>? links;

  /// The one store both entrances to a run read, so a link and a tile cannot
  /// disagree about whether this build is configured.
  ///
  /// A constructor argument rather than a `main`-level variable like
  /// [mainSecretStore]: that one exists only because `main` is an entrypoint
  /// and cannot take parameters. This is a widget, and every other widget in
  /// this app reaches its platform edges the same way.
  final SecretStore store;
  final Future<MintedSession> Function(Credentials, String body) mintWith;

  @override
  State<DemoHome> createState() => _DemoHomeState();
}

class _DemoHomeState extends State<DemoHome> {
  /// True from a link's arrival until the run it started has been left.
  ///
  /// Home's own tiles go dead while a run is being set up, but a tile cannot
  /// be tapped from under a pushed Run screen and a link can arrive at any
  /// moment. Without this a second `am start` while the first run is still
  /// open mints a second live session and stacks a second Run screen on it.
  ///
  /// Deliberately not `setState`: nothing renders this, and a link that
  /// rebuilt the tree under an open run would be a worse bug than this one.
  bool _busy = false;

  /// Says something on the channel a malformed link already uses.
  ///
  /// Silence reads as a broken build: the phone is in somebody's hand and the
  /// link they just fired did nothing they can see. What is said names the
  /// way out, or a type -- never a platform message, which can carry the URL
  /// that failed.
  void _say(BuildContext context, String message) {
    ScaffoldMessenger.maybeOf(
      context,
    )?.showSnackBar(SnackBar(content: Text(message)));
  }

  /// The one refusal with a way out to name: something is over Home.
  void _refuse(BuildContext context) =>
      _say(context, 'Link ignored — close the open screen first.');

  Future<void> _run(BuildContext context, Preset preset) async {
    // The parser already refuses every link in Live. This is the second
    // check, and it is here because this function is the only thing between a
    // link and a mint: a parser change that let one through would otherwise
    // charge a card.
    if (LiveModeScope.readOf(context)?.isLive ?? false) {
      _say(context, 'Live mode — links are disabled');
      return;
    }
    if (_busy) {
      _refuse(context);
      return;
    }
    // Anything pushed over Home -- a Run screen a tile started, Settings, the
    // editor -- makes Home no longer the current route; `_busy` only knows
    // about runs this widget started.
    if (!(ModalRoute.of(context)?.isCurrent ?? true)) {
      _refuse(context);
      return;
    }
    _busy = true;
    try {
      await runPreset(
        context,
        preset,
        preset.body,
        store: widget.store,
        mintWith: widget.mintWith,
      );
    } catch (problem) {
      // A link is fire-and-forget -- `onRun` returns void -- so anything that
      // escapes here has no owner and lands as an async error with no screen
      // attached. Only the type, for the reason `_say` gives.
      if (context.mounted) {
        _say(context, 'Could not start the run: ${problem.runtimeType}');
      }
    } finally {
      _busy = false;
    }
  }

  @override
  Widget build(BuildContext context) => DeepLinkListener(
    links: widget.links,
    onRun: (preset) => _run(context, preset),
    child: HomeScreen(store: widget.store, mintWith: widget.mintWith),
  );
}
0
likes
160
points
558
downloads

Documentation

API reference

Publisher

verified publisherpay-cross.com

Weekly Downloads

PayCross payment SDK for Flutter: card payments, 3-D Secure v2, saved cards, Google Pay on Android and Apple Pay on iOS, wrapping the native Android and iOS SDKs.

Repository (GitHub)
View/report issues

Topics

#payments #checkout #three-ds

License

MIT (license)

Dependencies

flutter, meta

More

Packages that depend on paycross_flutter

Packages that implement paycross_flutter