zerosettle 1.3.4 copy "zerosettle: ^1.3.4" to clipboard
zerosettle: ^1.3.4 copied to clipboard

ZeroSettle SDK for Flutter — Merchant of Record web checkout.

example/lib/main.dart

import 'dart:async';

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

import 'app_state.dart';
import 'debug/debug_account.dart';
import 'iap_environment.dart';
import 'identity_choice.dart';
import 'screens/entitlements_screen.dart';
import 'screens/home_screen.dart';
import 'screens/settings_screen.dart';
import 'screens/store_screen.dart';
import 'screens/transactions_screen.dart';
import 'widgets/identity_choice_sheet.dart';

void main() {
  runApp(const ZeroSettleExampleApp());
}

class ZeroSettleExampleApp extends StatelessWidget {
  const ZeroSettleExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'ZeroSettle',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorSchemeSeed: Colors.indigo,
        useMaterial3: true,
        brightness: Brightness.light,
      ),
      darkTheme: ThemeData(
        colorSchemeSeed: Colors.indigo,
        useMaterial3: true,
        brightness: Brightness.dark,
      ),
      home: const AppShell(),
    );
  }
}

class AppShell extends StatefulWidget {
  const AppShell({super.key});

  @override
  State<AppShell> createState() => _AppShellState();
}

class _AppShellState extends State<AppShell> {
  final _appState = AppState();
  late final IAPEnvironmentNotifier _envNotifier;
  int _currentTab = 0;
  StreamSubscription<List<Entitlement>>? _entitlementSub;
  bool _envLoaded = false;
  bool _identityPromptShown = false;
  MigrationManager? _migrationManager;

  @override
  void initState() {
    super.initState();
    _envNotifier = IAPEnvironmentNotifier(IAPEnvironment.sandbox);
    _bootstrapApp();
  }

  @override
  void dispose() {
    _entitlementSub?.cancel();
    _migrationManager?.dispose();
    _envNotifier.dispose();
    super.dispose();
  }

  Future<void> _bootstrapApp() async {
    final env = await IAPEnvironment.load();
    debugPrint('[ZS] bootstrap: loaded env=${env.name}');
    _envNotifier.value = env;
    setState(() => _envLoaded = true);

    // Pre-load the persisted identity BEFORE configure. The iOS Kit's
    // StoreKitManager starts listening to Transaction.updates inside
    // configure(); if Apple redelivers an unfinished transaction in the
    // window between configure() and identify(), the Kit's
    // handleVerifiedTransaction asserts (DEBUG) or leaves the txn
    // unfinished (RELEASE). Loading identity first shrinks that window
    // to just the bridge round-trip on identify().
    final stored = await IdentityChoiceStore.load();
    debugPrint('[ZS] bootstrap: persisted identity=${stored?.runtimeType ?? 'none'}');

    await _configureSdk(env);

    // Replay persisted identity choice, or prompt the user to pick one.
    if (stored != null) {
      await _applyIdentity(stored, persist: false);
    } else if (mounted) {
      // Defer the sheet to after first frame so the AppShell is mounted.
      WidgetsBinding.instance.addPostFrameCallback((_) => _promptForIdentity());
    }
  }

  Future<void> _configureSdk(IAPEnvironment env) async {
    debugPrint('[ZS] configure: start env=${env.name} baseUrl=${env.baseUrlOverride ?? 'default'} key=${env.truncatedKey}');
    _appState.setLoading(true);
    _appState.setError(null);

    try {
      // Set base URL override (must run before configure).
      await ZeroSettle.instance.setBaseUrlOverride(env.baseUrlOverride);

      // 1.3.0 configuration:
      // - syncStoreKitTransactions: forwards native StoreKit purchases to ZeroSettle.
      //   Set to false if you use RevenueCat or another aggregator.
      // - appleMerchantId: your Apple Pay merchant identifier. The SDK falls
      //   back to the dashboard-configured merchant if you omit this.
      // - preloadCheckout / maxPreloadedWebViews: pre-render WKWebViews so the
      //   first checkout opens with no network delay. Each pre-rendered view
      //   costs ~3-7 MB; cap with maxPreloadedWebViews or pass 0 to disable.
      await ZeroSettle.instance.configure(
        publishableKey: env.publishableKey,
        syncStoreKitTransactions: true,
        appleMerchantId: 'merchant.com.example.zerosettle.flutter',
        preloadCheckout: true,
        maxPreloadedWebViews: 3,
      );

      final isConfigured = await ZeroSettle.instance.getIsConfigured();
      debugPrint('[ZS] configure: done. SDK reports isConfigured=$isConfigured');

      // Subscribe to entitlement updates from the native SDK.
      _entitlementSub?.cancel();
      _entitlementSub =
          ZeroSettle.instance.entitlementUpdates.listen((entitlements) {
        _appState.setEntitlements(entitlements);
      });
    } on ZeroSettleException catch (e) {
      debugPrint('[ZS] configure: FAILED — ${e.message}');
      _appState.setError(e.message);
    } finally {
      _appState.setLoading(false);
    }
  }

  /// Apply an identity to the SDK and refresh user-scoped state.
  Future<void> _applyIdentity(Identity identity, {bool persist = true}) async {
    debugPrint('[ZS] identify: ${identity.runtimeType} (persist=$persist)');
    final preCheck = await ZeroSettle.instance.getIsConfigured();
    debugPrint('[ZS] identify: pre-call isConfigured=$preCheck');
    _appState.setLoading(true);
    _appState.setError(null);
    _appState.setIdentity(identity);

    try {
      final catalog = await ZeroSettle.instance.identify(identity);
      debugPrint('[ZS] identify: success, catalog=${catalog == null ? 'null (deferred)' : '${catalog.products.length} products'}');
      if (catalog != null) {
        _appState.setProducts(catalog.products);
        _appState.setRemoteConfig(catalog.config);
      }
      _appState.setInitialized(true);

      // restoreEntitlements requires a non-deferred identity. Skip on deferred.
      if (identity is! IdentityDeferred) {
        try {
          final entitlements = await ZeroSettle.instance.restoreEntitlements();
          _appState.setEntitlements(entitlements);
        } catch (_) {
          // Non-fatal: entitlements may be empty for new users.
        }
      }

      // Wire up the headless migration manager so adopters can drive a
      // custom UI from MigrationManager.stateUpdates. The drop-in
      // MigrationTipView widget is the alternative; this example uses the
      // headless path on the Home screen.
      try {
        await _migrationManager?.dispose();
        final mgr = await ZeroSettle.instance.migrationManager();
        if (mounted) setState(() => _migrationManager = mgr);
      } catch (_) {
        // Non-fatal — the home screen handles a null manager.
      }
    } on ZeroSettleException catch (e) {
      debugPrint('[ZS] identify: FAILED — ${e.runtimeType}: ${e.message}');
      _appState.setError(e.message);
    } finally {
      _appState.setLoading(false);
    }

    if (persist) {
      await IdentityChoiceStore.save(identity);
    }
  }

  Future<void> _promptForIdentity() async {
    if (_identityPromptShown || !mounted) return;
    _identityPromptShown = true;

    Identity? choice;
    while (choice == null && mounted) {
      choice = await IdentityChoiceSheet.show(
        context,
        dismissible: false,
        current: _appState.currentIdentity,
      );
    }
    if (choice == null) return; // unmounted
    await _applyIdentity(choice);
    _identityPromptShown = false;
  }

  /// Public entry point used by [SettingsScreen] for switching identity.
  Future<void> switchIdentity() async {
    final choice = await IdentityChoiceSheet.show(
      context,
      dismissible: true,
      current: _appState.currentIdentity,
    );
    if (choice != null) {
      await _applyIdentity(choice);
    }
  }

  /// Public entry point used by [SettingsScreen] for sign-out.
  Future<void> signOut() async {
    try {
      await ZeroSettle.instance.logout();
    } on ZeroSettleException {
      // Logout failures are non-fatal — clear local state regardless.
    }
    await IdentityChoiceStore.clear();
    await _migrationManager?.dispose();
    _migrationManager = null;
    _appState.setIdentity(null);
    _appState.setInitialized(false);
    _appState.setProducts([]);
    _appState.setEntitlements([]);
    if (mounted) {
      WidgetsBinding.instance.addPostFrameCallback((_) => _promptForIdentity());
    }
  }

  Future<void> _switchEnvironment(IAPEnvironment env) async {
    await _envNotifier.switchTo(env);
    _appState.setInitialized(false);
    _appState.setProducts([]);
    _appState.setEntitlements([]);
    await _configureSdk(env);

    // Re-apply identity to refresh products/entitlements against the new env.
    final identity = _appState.currentIdentity;
    if (identity != null) {
      await _applyIdentity(identity, persist: false);
    }
  }

  // -- Debug-only callbacks (consumed by DebugSettingsScreen). --
  //
  // These bypass the user-facing identity sheet so engineers can rebootstrap
  // the SDK at runtime without UI noise. The methods are still safe in
  // release because the screen that calls them is gated behind kDebugMode.

  /// Switch env at runtime. If [restoreIdentity] is provided, log out and
  /// re-identify as that account. Otherwise, preserve the current identity
  /// (and its persisted choice) and just re-bootstrap the SDK against the
  /// new env.
  ///
  /// Does NOT trigger the identity sheet.
  Future<void> _applyDebugEnv(
    IAPEnvironment env, {
    Identity? restoreIdentity,
  }) async {
    if (restoreIdentity != null) {
      // Caller picked an explicit account to restore in the new env. Sign
      // out, swap env, and identify as the requested account.
      try {
        await ZeroSettle.instance.logout();
      } on ZeroSettleException {
        // Non-fatal — we still want to swap env.
      }
      await _migrationManager?.dispose();
      _migrationManager = null;
      _appState.setIdentity(null);
      _appState.setInitialized(false);
      _appState.setProducts([]);
      _appState.setEntitlements([]);

      await _envNotifier.switchTo(env);
      await _configureSdk(env);
      await _applyIdentity(restoreIdentity);
      return;
    }

    // No explicit account to restore: keep the current identity (and its
    // persisted choice) so the user isn't surprised by an identity sheet
    // on next launch. Just re-bootstrap the SDK against the new env.
    final current = _appState.currentIdentity;
    _appState.setInitialized(false);
    _appState.setProducts([]);
    _appState.setEntitlements([]);

    await _envNotifier.switchTo(env);
    await _configureSdk(env);

    if (current != null) {
      await _applyIdentity(current, persist: false);
    }
  }

  /// Logout and identify as the user described by [account]. Persists via
  /// [IdentityChoiceStore].
  Future<void> _switchToDebugAccount(DebugAccount account) async {
    try {
      await ZeroSettle.instance.logout();
    } on ZeroSettleException {
      // Non-fatal — proceed to identify.
    }
    await _applyIdentity(
      Identity.user(id: account.id, name: account.label),
    );
  }

  /// Logout and clear local state without showing the identity sheet.
  Future<void> _debugClearIdentity() async {
    try {
      await ZeroSettle.instance.logout();
    } on ZeroSettleException {
      // Non-fatal — clear local state regardless.
    }
    await IdentityChoiceStore.clear();
    await _migrationManager?.dispose();
    _migrationManager = null;
    _appState.setIdentity(null);
    _appState.setInitialized(false);
    _appState.setProducts([]);
    _appState.setEntitlements([]);
  }

  @override
  Widget build(BuildContext context) {
    if (!_envLoaded) {
      return _buildLoadingScreen(context);
    }

    return ListenableBuilder(
      listenable: _appState,
      builder: (context, _) {
        // Show the loading screen until SDK has at least been configured.
        if (!_appState.isInitialized && _appState.isLoading) {
          return _buildLoadingScreen(context);
        }

        return Scaffold(
          body: IndexedStack(
            index: _currentTab,
            children: [
              HomeScreen(
                appState: _appState,
                onNavigateToStore: () => setState(() => _currentTab = 1),
                onSignIn: switchIdentity,
                migrationManager: _migrationManager,
              ),
              StoreScreen(appState: _appState, onSignIn: switchIdentity),
              EntitlementsScreen(appState: _appState),
              TransactionsScreen(appState: _appState),
              SettingsScreen(
                appState: _appState,
                envNotifier: _envNotifier,
                onSwitchEnvironment: _switchEnvironment,
                onSwitchIdentity: switchIdentity,
                onSignOut: signOut,
                onApplyDebugEnv: _applyDebugEnv,
                onSwitchToDebugAccount: _switchToDebugAccount,
                onDebugClearIdentity: _debugClearIdentity,
              ),
            ],
          ),
          bottomNavigationBar: NavigationBar(
            selectedIndex: _currentTab,
            onDestinationSelected: (index) =>
                setState(() => _currentTab = index),
            destinations: const [
              NavigationDestination(
                icon: Icon(Icons.home_outlined),
                selectedIcon: Icon(Icons.home),
                label: 'Home',
              ),
              NavigationDestination(
                icon: Icon(Icons.store_outlined),
                selectedIcon: Icon(Icons.store),
                label: 'Store',
              ),
              NavigationDestination(
                icon: Icon(Icons.verified_outlined),
                selectedIcon: Icon(Icons.verified),
                label: 'Entitlements',
              ),
              NavigationDestination(
                icon: Icon(Icons.receipt_long_outlined),
                selectedIcon: Icon(Icons.receipt_long),
                label: 'Transactions',
              ),
              NavigationDestination(
                icon: Icon(Icons.settings_outlined),
                selectedIcon: Icon(Icons.settings),
                label: 'Settings',
              ),
            ],
          ),
        );
      },
    );
  }

  Widget _buildLoadingScreen(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: Theme.of(context),
      home: Scaffold(
        body: Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Container(
                width: 72,
                height: 72,
                decoration: const BoxDecoration(
                  gradient: LinearGradient(
                    colors: [Colors.indigo, Colors.purple],
                    begin: Alignment.topLeft,
                    end: Alignment.bottomRight,
                  ),
                  shape: BoxShape.circle,
                ),
                child:
                    const Icon(Icons.diamond, size: 32, color: Colors.white),
              ),
              const SizedBox(height: 24),
              Text(
                'ZeroSettle',
                style: Theme.of(context)
                    .textTheme
                    .headlineMedium
                    ?.copyWith(fontWeight: FontWeight.bold),
              ),
              const SizedBox(height: 24),
              const CircularProgressIndicator(),
              const SizedBox(height: 16),
              Text(
                'Initializing...',
                style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                      color: Theme.of(context).colorScheme.onSurfaceVariant,
                    ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}
2
likes
140
points
106
downloads

Documentation

API reference

Publisher

verified publisherzerosettle.io

Weekly Downloads

ZeroSettle SDK for Flutter — Merchant of Record web checkout.

Homepage
Repository (GitHub)
View/report issues

License

Apache-2.0 (license)

Dependencies

flutter, plugin_platform_interface

More

Packages that depend on zerosettle

Packages that implement zerosettle