bmoni_embedded_sdk 0.0.1 copy "bmoni_embedded_sdk: ^0.0.1" to clipboard
bmoni_embedded_sdk: ^0.0.1 copied to clipboard

Bmoni Embedded SDK for Flutter — Ethereum wallet provisioning and signing backed by Android Keystore / iOS Secure Enclave.

example/lib/main.dart

import 'dart:async';

import 'package:bmoni_embedded_sdk/bmoni_embedded_sdk.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() {
  // Initialize the SDK with the default PIN policy. The Settings sheet
  // in the example app lets you toggle these knobs at runtime so you
  // can experience both gating modes.
  BmoniEmbeddedSdk.initialize(pinLength: 6, requirePin: true);
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    final ColorScheme scheme = ColorScheme.fromSeed(
      seedColor: const Color(0xFF6750A4),
      brightness: Brightness.light,
    );
    return MaterialApp(
      title: 'BMONI Embedded SDK',
      theme: ThemeData(
        colorScheme: scheme,
        useMaterial3: true,
        filledButtonTheme: FilledButtonThemeData(
          style: FilledButton.styleFrom(
            minimumSize: const Size.fromHeight(48),
            shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.circular(12),
            ),
          ),
        ),
        outlinedButtonTheme: OutlinedButtonThemeData(
          style: OutlinedButton.styleFrom(
            minimumSize: const Size.fromHeight(48),
            shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.circular(12),
            ),
          ),
        ),
        inputDecorationTheme: const InputDecorationTheme(
          border: OutlineInputBorder(),
          isDense: true,
        ),
      ),
      home: const _HomeScreen(),
    );
  }
}

class _HomeScreen extends StatefulWidget {
  const _HomeScreen();

  @override
  State<_HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<_HomeScreen> {
  final TextEditingController _messageController = TextEditingController(
    text: 'Welcome to BMONI!',
  );
  final TextEditingController _hashController = TextEditingController(
    text: '0x1c8aff950685c2ed4bc3174f3472287b56d9517b9c948127319a09a7a36deac8',
  );

  String? _walletAddress;
  String? _lastSignature;
  bool _hasPin = false;
  bool _busy = false;
  late BmoniEmbeddedSdkConfig _config;

  @override
  void initState() {
    super.initState();
    _config = BmoniEmbeddedSdk.config;
    _hydrateFromStorage();
  }

  /// Loads any persistent SDK state (PIN existence + cached wallet
  /// address) into this widget so the UI reflects the device's actual
  /// state on cold start.
  Future<void> _hydrateFromStorage() async {
    final results = await Future.wait<Object?>(<Future<Object?>>[
      BmoniEmbeddedSdk.hasPin(),
      BmoniEmbeddedSdk.walletAddress(),
    ]);
    if (!mounted) return;
    setState(() {
      _hasPin = results[0]! as bool;
      _walletAddress = results[1] as String?;
    });
  }

  @override
  void dispose() {
    _messageController.dispose();
    _hashController.dispose();
    super.dispose();
  }

  Future<void> _refreshHasPin() async {
    final bool hasPin = await BmoniEmbeddedSdk.hasPin();
    if (!mounted) return;
    setState(() => _hasPin = hasPin);
  }

  void _showSnack(String message, {bool error = false}) {
    final ColorScheme scheme = Theme.of(context).colorScheme;
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(message),
        behavior: SnackBarBehavior.floating,
        backgroundColor: error ? scheme.error : scheme.secondary,
        showCloseIcon: true,
      ),
    );
  }

  Future<T?> _runAction<T>(Future<T> Function() action) async {
    setState(() => _busy = true);
    try {
      return await action();
    } on BmoniSignerException catch (e) {
      _showSnack(e.message, error: true);
    } on PlatformException catch (e) {
      _showSnack('Platform error: ${e.message ?? e.code}', error: true);
    } finally {
      if (mounted) setState(() => _busy = false);
    }
    return null;
  }

  // ----------------------------------------------------------------
  // Wallet flows
  // ----------------------------------------------------------------

  Future<void> _initWallet() async {
    setState(() => _busy = true);
    try {
      final String address = await BmoniEmbeddedSdk.initWallet();
      if (!mounted) return;
      setState(() {
        _walletAddress = address;
        _lastSignature = null;
      });
      _showSnack('Wallet provisioned.');
    } on BmoniSignerException catch (e) {
      if (e.errorCode == BmoniSignerErrorCode.walletAlreadyExists && mounted) {
        // Native wallet exists but the Dart-side address cache is
        // empty — most commonly because the wallet was provisioned
        // before the cache existed. Offer the destructive recovery
        // path (delete + re-provision).
        await _offerWalletRecovery();
      } else {
        _showSnack(e.message, error: true);
      }
    } on PlatformException catch (e) {
      _showSnack('Platform error: ${e.message ?? e.code}', error: true);
    } finally {
      if (mounted) setState(() => _busy = false);
    }
  }

  Future<void> _offerWalletRecovery() async {
    final bool? confirmed = await showDialog<bool>(
      context: context,
      builder: (BuildContext ctx) => AlertDialog(
        title: const Text('A wallet already exists'),
        content: const Text(
          'This device has a wallet provisioned by BMONISigner, but '
          'the SDK cache does not know its address.\n\n'
          'The native SDK only returns the address at provisioning '
          'time, so the only way to get back to a usable state from '
          'here is to delete the existing wallet and provision a new '
          'one.\n\n'
          'This is destructive — the old address is unrecoverable '
          'from this device after deletion.',
        ),
        actions: <Widget>[
          TextButton(
            onPressed: () => Navigator.of(ctx).pop(false),
            child: const Text('Cancel'),
          ),
          FilledButton(
            style: FilledButton.styleFrom(
              backgroundColor: Theme.of(ctx).colorScheme.errorContainer,
              foregroundColor: Theme.of(ctx).colorScheme.onErrorContainer,
            ),
            onPressed: () => Navigator.of(ctx).pop(true),
            child: const Text('Delete & re-provision'),
          ),
        ],
      ),
    );
    if (confirmed != true || !mounted) return;

    // The native delete is gated on the PIN when requirePin is true,
    // but the cache state is inconsistent enough that we take the
    // escape hatch: drop the PIN first (destructive, but consistent
    // with "reset everything to a clean slate") and then delete.
    try {
      setState(() => _busy = true);
      // Flip the gate off so we can blow away the stale wallet without
      // knowing its PIN, then restore the original config.
      BmoniEmbeddedSdk.initialize(requirePin: false);
      await BmoniEmbeddedSdk.deleteWallet();
      BmoniEmbeddedSdk.initialize(
        pinLength: _config.pinLength,
        requirePin: _config.requirePin,
      );
      final String address = await BmoniEmbeddedSdk.initWallet();
      if (!mounted) return;
      setState(() {
        _walletAddress = address;
        _lastSignature = null;
      });
      _showSnack('Wallet re-provisioned.');
    } on BmoniSignerException catch (e) {
      _showSnack('Recovery failed: ${e.message}', error: true);
    } on PlatformException catch (e) {
      _showSnack('Platform error: ${e.message ?? e.code}', error: true);
    } finally {
      if (mounted) setState(() => _busy = false);
    }
  }

  Future<void> _deleteWallet() async {
    final _GatedPin? gated = await _resolveGatedPin(
      title: 'Delete wallet',
      subtitle:
          'This permanently removes the encrypted private key from this device.',
      confirmLabel: 'Delete',
      destructive: true,
    );
    if (gated == null) return;
    final bool? ok = await _runAction(() async {
      await BmoniEmbeddedSdk.deleteWallet(pin: gated.pin);
      return true;
    });
    if (ok != true || !mounted) return;
    setState(() {
      _walletAddress = null;
      _lastSignature = null;
    });
    _showSnack('Wallet deleted.');
  }

  // ----------------------------------------------------------------
  // PIN flows
  // ----------------------------------------------------------------

  Future<void> _setPin() async {
    final String? pin = await _PinEntrySheet.show(
      context,
      title: 'Create a PIN',
      subtitle:
          'Used to unlock signing and wallet deletion. Store it somewhere safe.',
      confirmLabel: 'Save PIN',
    );
    if (pin == null) return;
    final bool? ok = await _runAction(() async {
      await BmoniEmbeddedSdk.setPin(pin);
      return true;
    });
    if (ok != true || !mounted) return;
    await _refreshHasPin();
    _showSnack('PIN set.');
  }

  Future<void> _changePin() async {
    final _ChangePinResult? result = await _ChangePinSheet.show(context);
    if (result == null) return;
    final bool? ok = await _runAction(() async {
      await BmoniEmbeddedSdk.changePin(
        currentPin: result.currentPin,
        newPin: result.newPin,
      );
      return true;
    });
    if (ok != true || !mounted) return;
    _showSnack('PIN changed.');
  }

  Future<void> _removePin() async {
    final String? pin = await _PinEntrySheet.show(
      context,
      title: 'Remove PIN',
      subtitle:
          'Signing and delete will be unavailable until you create a new PIN.',
      confirmLabel: 'Remove',
      destructive: true,
    );
    if (pin == null) return;
    final bool? ok = await _runAction(() async {
      await BmoniEmbeddedSdk.removePin(pin);
      return true;
    });
    if (ok != true || !mounted) return;
    await _refreshHasPin();
    _showSnack('PIN removed.');
  }

  Future<void> _matchPin() async {
    final String? pin = await _PinEntrySheet.show(
      context,
      title: 'Verify PIN',
      subtitle: 'Check that the PIN you remember matches the stored one.',
      confirmLabel: 'Verify',
    );
    if (pin == null) return;
    final bool? matches = await _runAction(
      () => BmoniEmbeddedSdk.matchPin(pin),
    );
    if (matches == null || !mounted) return;
    _showSnack(
      matches ? 'PIN matches.' : 'PIN does NOT match.',
      error: !matches,
    );
  }

  // ----------------------------------------------------------------
  // Signing flows
  // ----------------------------------------------------------------

  Future<void> _signMessage() async {
    if (_messageController.text.isEmpty) {
      _showSnack('Enter a message to sign.', error: true);
      return;
    }
    final _GatedPin? gated = await _resolveGatedPin(
      title: 'Sign message',
      subtitle: '"${_truncate(_messageController.text, 60)}"',
      confirmLabel: 'Sign',
    );
    if (gated == null) return;
    final String? signature = await _runAction(
      () =>
          BmoniEmbeddedSdk.signMessage(_messageController.text, pin: gated.pin),
    );
    if (signature == null || !mounted) return;
    setState(() => _lastSignature = signature);
  }

  Future<void> _signHash() async {
    if (_hashController.text.isEmpty) {
      _showSnack('Enter a 32-byte hash to sign.', error: true);
      return;
    }
    final _GatedPin? gated = await _resolveGatedPin(
      title: 'Sign hash',
      subtitle: _truncate(_hashController.text, 50),
      confirmLabel: 'Sign',
    );
    if (gated == null) return;
    final String? signature = await _runAction(
      () => BmoniEmbeddedSdk.signTransactionHash(
        _hashController.text,
        pin: gated.pin,
      ),
    );
    if (signature == null || !mounted) return;
    setState(() => _lastSignature = signature);
  }

  /// When `requirePin` is `true`, prompts the user with a PIN sheet and
  /// returns the entered value (or `null` if dismissed). When
  /// `requirePin` is `false`, returns a sentinel without showing any
  /// UI so the caller can proceed straight to the SDK call.
  Future<_GatedPin?> _resolveGatedPin({
    required String title,
    required String confirmLabel,
    String? subtitle,
    bool destructive = false,
  }) async {
    if (!_config.requirePin) {
      return const _GatedPin(null);
    }
    final String? pin = await _PinEntrySheet.show(
      context,
      title: title,
      subtitle: subtitle,
      confirmLabel: confirmLabel,
      destructive: destructive,
    );
    if (pin == null) return null;
    return _GatedPin(pin);
  }

  String _truncate(String value, int max) =>
      value.length <= max ? value : '${value.substring(0, max)}…';

  Future<void> _openSettings() async {
    final BmoniEmbeddedSdkConfig? next = await _SettingsSheet.show(
      context,
      current: _config,
    );
    if (next == null) return;
    BmoniEmbeddedSdk.initialize(
      pinLength: next.pinLength,
      requirePin: next.requirePin,
    );
    if (!mounted) return;
    setState(() => _config = BmoniEmbeddedSdk.config);
    _showSnack(
      'SDK reconfigured: pinLength=${next.pinLength}, requirePin=${next.requirePin}.',
    );
  }

  // ----------------------------------------------------------------
  // Build
  // ----------------------------------------------------------------

  @override
  Widget build(BuildContext context) {
    final ColorScheme scheme = Theme.of(context).colorScheme;

    return Scaffold(
      appBar: AppBar(
        title: const Text('BMONI Embedded SDK'),
        scrolledUnderElevation: 0,
        actions: <Widget>[
          IconButton(
            tooltip: 'SDK settings',
            icon: const Icon(Icons.settings_outlined),
            onPressed: _busy ? null : _openSettings,
          ),
        ],
      ),
      body: Stack(
        children: <Widget>[
          SafeArea(
            child: ListView(
              padding: const EdgeInsets.all(16),
              children: <Widget>[
                _StatusBanner(
                  walletAddress: _walletAddress,
                  hasPin: _hasPin,
                  config: _config,
                ),
                const SizedBox(height: 16),
                _SectionCard(
                  icon: Icons.account_balance_wallet_outlined,
                  title: 'Wallet',
                  children: <Widget>[
                    if (_walletAddress != null) ...<Widget>[
                      _MonoBox(label: 'Address', value: _walletAddress!),
                      const SizedBox(height: 12),
                      _DestructiveGatedButton(
                        enabled: !_busy && _canSign(),
                        missingRequirement: _missingRequirement(),
                        onPressed: _deleteWallet,
                        icon: Icons.delete_outline,
                        label: 'Delete wallet',
                      ),
                    ] else ...<Widget>[
                      FilledButton.icon(
                        icon: const Icon(Icons.add_circle_outline),
                        label: const Text('Provision wallet'),
                        onPressed: _busy ? null : _initWallet,
                      ),
                      const SizedBox(height: 8),
                      // Always available even before the user has tried
                      // to provision: handles the post-upgrade case
                      // where a native wallet exists but the Dart-side
                      // address cache is empty (so `Provision wallet`
                      // would just throw `walletAlreadyExists`).
                      TextButton.icon(
                        icon: Icon(Icons.restart_alt, color: scheme.error),
                        label: Text(
                          'Already have a wallet on this device? Reset it',
                          style: TextStyle(color: scheme.error),
                        ),
                        onPressed: _busy ? null : _offerWalletRecovery,
                      ),
                    ],
                  ],
                ),
                const SizedBox(height: 16),
                _SectionCard(
                  icon: Icons.lock_outline,
                  title: 'PIN',
                  subtitle: _pinSectionSubtitle(),
                  children: <Widget>[
                    if (!_hasPin)
                      FilledButton.icon(
                        icon: const Icon(Icons.password),
                        label: const Text('Set PIN'),
                        onPressed: _busy ? null : _setPin,
                      )
                    else ...<Widget>[
                      OutlinedButton.icon(
                        icon: const Icon(Icons.cached),
                        label: const Text('Change PIN'),
                        onPressed: _busy ? null : _changePin,
                      ),
                      const SizedBox(height: 8),
                      OutlinedButton.icon(
                        icon: const Icon(Icons.check_circle_outline),
                        label: const Text('Verify PIN'),
                        onPressed: _busy ? null : _matchPin,
                      ),
                      const SizedBox(height: 8),
                      TextButton.icon(
                        icon: Icon(
                          Icons.delete_forever_outlined,
                          color: scheme.error,
                        ),
                        label: Text(
                          'Remove PIN',
                          style: TextStyle(color: scheme.error),
                        ),
                        onPressed: _busy ? null : _removePin,
                      ),
                    ],
                  ],
                ),
                const SizedBox(height: 16),
                _SectionCard(
                  icon: Icons.short_text,
                  title: 'Sign message (EIP-191)',
                  subtitle:
                      'Applies the personal_sign prefix and signs with secp256k1.',
                  children: <Widget>[
                    TextField(
                      controller: _messageController,
                      maxLines: 3,
                      minLines: 1,
                      decoration: const InputDecoration(labelText: 'Message'),
                    ),
                    const SizedBox(height: 12),
                    _GatedButton(
                      enabled: !_busy && _canSign(),
                      missingRequirement: _missingRequirement(),
                      onPressed: _signMessage,
                      icon: Icons.draw_outlined,
                      label: 'Sign message',
                    ),
                  ],
                ),
                const SizedBox(height: 16),
                _SectionCard(
                  icon: Icons.fingerprint,
                  title: 'Sign transaction hash',
                  subtitle:
                      'Signs a pre-computed 32-byte digest (userOpHash, EIP-712, …).',
                  children: <Widget>[
                    TextField(
                      controller: _hashController,
                      decoration: const InputDecoration(
                        labelText: '32-byte hash (hex)',
                      ),
                      style: const TextStyle(
                        fontFamily: 'monospace',
                        fontSize: 13,
                      ),
                    ),
                    const SizedBox(height: 12),
                    _GatedButton(
                      enabled: !_busy && _canSign(),
                      missingRequirement: _missingRequirement(),
                      onPressed: _signHash,
                      icon: Icons.draw_outlined,
                      label: 'Sign hash',
                    ),
                  ],
                ),
                if (_lastSignature != null) ...<Widget>[
                  const SizedBox(height: 16),
                  _SectionCard(
                    icon: Icons.check_circle_outline,
                    title: 'Latest signature',
                    children: <Widget>[
                      _MonoBox(label: 'r ‖ s ‖ v', value: _lastSignature!),
                    ],
                  ),
                ],
                const SizedBox(height: 24),
              ],
            ),
          ),
          if (_busy)
            const Positioned.fill(
              child: ColoredBox(
                color: Color(0x33000000),
                child: Center(child: CircularProgressIndicator()),
              ),
            ),
        ],
      ),
    );
  }

  /// Whether the sign / delete buttons should be enabled.
  ///
  /// When `requirePin` is `true`, both a wallet **and** a PIN must
  /// exist. When `requirePin` is `false`, only a wallet is needed —
  /// the PIN is bypassed entirely.
  bool _canSign() {
    if (_walletAddress == null) return false;
    if (_config.requirePin) return _hasPin;
    return true;
  }

  /// Returns a user-facing description of the first missing
  /// prerequisite, or `null` if every gate is satisfied. Fed into
  /// [_GatedButton] so a disabled sign / delete button surfaces a
  /// tooltip explaining exactly what's still required.
  String? _missingRequirement() {
    if (_canSign()) return null;
    final bool needsWallet = _walletAddress == null;
    final bool needsPin = _config.requirePin && !_hasPin;
    if (needsWallet && needsPin) {
      return 'Provision a wallet and set a PIN first.';
    }
    if (needsWallet) return 'Provision a wallet first.';
    if (needsPin) return 'Set a PIN first.';
    return null;
  }

  String _pinSectionSubtitle() {
    if (!_config.requirePin) {
      return _hasPin
          ? 'A PIN is set, but gating is OFF — sign / delete bypass it.'
          : 'PIN gating is OFF — set / change / remove still work for app-side use.';
    }
    return _hasPin
        ? 'A PIN is set on this device.'
        : 'No PIN — sign and delete are unavailable until you set one.';
  }
}

/// Result returned from [_resolveGatedPin]: a non-null wrapper that
/// carries the (possibly null) PIN to forward to the SDK call. A null
/// wrapper means "user dismissed the prompt" and the caller should
/// abort.
class _GatedPin {
  const _GatedPin(this.pin);
  final String? pin;
}

// --------------------------------------------------------------------
// Header banner
// --------------------------------------------------------------------

class _StatusBanner extends StatelessWidget {
  const _StatusBanner({
    required this.walletAddress,
    required this.hasPin,
    required this.config,
  });

  final String? walletAddress;
  final bool hasPin;
  final BmoniEmbeddedSdkConfig config;

  bool get _ready => walletAddress != null && (!config.requirePin || hasPin);

  @override
  Widget build(BuildContext context) {
    final ColorScheme scheme = Theme.of(context).colorScheme;
    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: scheme.primaryContainer,
        borderRadius: BorderRadius.circular(16),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: <Widget>[
          Row(
            children: <Widget>[
              Icon(
                _ready
                    ? Icons.verified_user_outlined
                    : Icons.shield_moon_outlined,
                color: scheme.onPrimaryContainer,
                size: 32,
              ),
              const SizedBox(width: 16),
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Text(
                      _ready ? 'Ready to sign' : 'Setup required',
                      style: Theme.of(context).textTheme.titleMedium?.copyWith(
                        color: scheme.onPrimaryContainer,
                        fontWeight: FontWeight.w600,
                      ),
                    ),
                    const SizedBox(height: 4),
                    Text(
                      _statusLine(walletAddress, hasPin, config),
                      style: TextStyle(color: scheme.onPrimaryContainer),
                    ),
                  ],
                ),
              ),
            ],
          ),
          const SizedBox(height: 12),
          Wrap(
            spacing: 8,
            runSpacing: 4,
            children: <Widget>[
              _ConfigChip(
                label: 'PIN length: ${config.pinLength}',
                scheme: scheme,
              ),
              _ConfigChip(
                label: 'requirePin: ${config.requirePin}',
                scheme: scheme,
              ),
            ],
          ),
        ],
      ),
    );
  }

  String _statusLine(
    String? address,
    bool hasPin,
    BmoniEmbeddedSdkConfig config,
  ) {
    if (!config.requirePin) {
      return address == null
          ? 'PIN gating is OFF — provision a wallet to start signing.'
          : 'PIN gating is OFF — sign / delete are called directly.';
    }
    if (address == null && !hasPin) {
      return 'Provision a wallet, then create a PIN to start signing.';
    }
    if (address == null) {
      return 'PIN is set — provision a wallet to start signing.';
    }
    if (!hasPin) {
      return 'Wallet provisioned — create a PIN to enable signing.';
    }
    return 'Wallet provisioned and PIN set.';
  }
}

class _ConfigChip extends StatelessWidget {
  const _ConfigChip({required this.label, required this.scheme});

  final String label;
  final ColorScheme scheme;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
      decoration: BoxDecoration(
        color: scheme.primary.withValues(alpha: 0.15),
        borderRadius: BorderRadius.circular(999),
      ),
      child: Text(
        label,
        style: TextStyle(
          color: scheme.onPrimaryContainer,
          fontFamily: 'monospace',
          fontSize: 12,
        ),
      ),
    );
  }
}

// --------------------------------------------------------------------
// Section card
// --------------------------------------------------------------------

class _SectionCard extends StatelessWidget {
  const _SectionCard({
    required this.icon,
    required this.title,
    this.subtitle,
    required this.children,
  });

  final IconData icon;
  final String title;
  final String? subtitle;
  final List<Widget> children;

  @override
  Widget build(BuildContext context) {
    final TextTheme text = Theme.of(context).textTheme;
    final ColorScheme scheme = Theme.of(context).colorScheme;
    return Card(
      elevation: 0,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(16),
        side: BorderSide(color: scheme.outlineVariant),
      ),
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: <Widget>[
            Row(
              children: <Widget>[
                Icon(icon, color: scheme.primary),
                const SizedBox(width: 12),
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[
                      Text(title, style: text.titleMedium),
                      if (subtitle != null) ...<Widget>[
                        const SizedBox(height: 2),
                        Text(
                          subtitle!,
                          style: text.bodySmall?.copyWith(
                            color: scheme.onSurfaceVariant,
                          ),
                        ),
                      ],
                    ],
                  ),
                ),
              ],
            ),
            const SizedBox(height: 16),
            ...children,
          ],
        ),
      ),
    );
  }
}

class _MonoBox extends StatelessWidget {
  const _MonoBox({required this.label, required this.value});

  final String label;
  final String value;

  @override
  Widget build(BuildContext context) {
    final ColorScheme scheme = Theme.of(context).colorScheme;
    return Container(
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: scheme.surfaceContainerHigh,
        borderRadius: BorderRadius.circular(12),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Text(
            label,
            style: Theme.of(
              context,
            ).textTheme.labelSmall?.copyWith(color: scheme.onSurfaceVariant),
          ),
          const SizedBox(height: 4),
          SelectableText(
            value,
            style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
          ),
        ],
      ),
    );
  }
}

// --------------------------------------------------------------------
// PIN entry modal sheets
// --------------------------------------------------------------------

class _PinEntrySheet extends StatefulWidget {
  const _PinEntrySheet({
    required this.title,
    required this.confirmLabel,
    this.subtitle,
    this.destructive = false,
  });

  final String title;
  final String? subtitle;
  final String confirmLabel;
  final bool destructive;

  /// Shows the sheet and returns the entered PIN, or `null` if dismissed.
  static Future<String?> show(
    BuildContext context, {
    required String title,
    required String confirmLabel,
    String? subtitle,
    bool destructive = false,
  }) {
    return showModalBottomSheet<String>(
      context: context,
      isScrollControlled: true,
      showDragHandle: true,
      builder: (BuildContext ctx) => _PinEntrySheet(
        title: title,
        confirmLabel: confirmLabel,
        subtitle: subtitle,
        destructive: destructive,
      ),
    );
  }

  @override
  State<_PinEntrySheet> createState() => _PinEntrySheetState();
}

class _PinEntrySheetState extends State<_PinEntrySheet> {
  final TextEditingController _controller = TextEditingController();

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  void _submit() {
    if (_controller.text.length != BmoniEmbeddedSdk.pinLength) return;
    Navigator.of(context).pop(_controller.text);
  }

  @override
  Widget build(BuildContext context) {
    final EdgeInsets viewInsets = MediaQuery.viewInsetsOf(context);
    return Padding(
      padding: EdgeInsets.only(bottom: viewInsets.bottom),
      child: Padding(
        padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: <Widget>[
            Text(widget.title, style: Theme.of(context).textTheme.titleLarge),
            if (widget.subtitle != null) ...<Widget>[
              const SizedBox(height: 8),
              Text(
                widget.subtitle!,
                style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                  color: Theme.of(context).colorScheme.onSurfaceVariant,
                ),
              ),
            ],
            const SizedBox(height: 24),
            _PinField(
              controller: _controller,
              onSubmitted: (_) => _submit(),
              onChanged: (_) => setState(() {}),
              autofocus: true,
            ),
            const SizedBox(height: 24),
            FilledButton(
              style: widget.destructive
                  ? FilledButton.styleFrom(
                      backgroundColor: Theme.of(
                        context,
                      ).colorScheme.errorContainer,
                      foregroundColor: Theme.of(
                        context,
                      ).colorScheme.onErrorContainer,
                    )
                  : null,
              onPressed: _controller.text.length == BmoniEmbeddedSdk.pinLength
                  ? _submit
                  : null,
              child: Text(widget.confirmLabel),
            ),
          ],
        ),
      ),
    );
  }
}

class _ChangePinResult {
  const _ChangePinResult({required this.currentPin, required this.newPin});

  final String currentPin;
  final String newPin;
}

class _ChangePinSheet extends StatefulWidget {
  const _ChangePinSheet();

  static Future<_ChangePinResult?> show(BuildContext context) {
    return showModalBottomSheet<_ChangePinResult>(
      context: context,
      isScrollControlled: true,
      showDragHandle: true,
      builder: (BuildContext ctx) => const _ChangePinSheet(),
    );
  }

  @override
  State<_ChangePinSheet> createState() => _ChangePinSheetState();
}

class _ChangePinSheetState extends State<_ChangePinSheet> {
  final TextEditingController _current = TextEditingController();
  final TextEditingController _next = TextEditingController();

  @override
  void dispose() {
    _current.dispose();
    _next.dispose();
    super.dispose();
  }

  bool get _isValid =>
      _current.text.length == BmoniEmbeddedSdk.pinLength &&
      _next.text.length == BmoniEmbeddedSdk.pinLength;

  void _submit() {
    if (!_isValid) return;
    Navigator.of(
      context,
    ).pop(_ChangePinResult(currentPin: _current.text, newPin: _next.text));
  }

  @override
  Widget build(BuildContext context) {
    final EdgeInsets viewInsets = MediaQuery.viewInsetsOf(context);
    return Padding(
      padding: EdgeInsets.only(bottom: viewInsets.bottom),
      child: Padding(
        padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: <Widget>[
            Text('Change PIN', style: Theme.of(context).textTheme.titleLarge),
            const SizedBox(height: 8),
            Text(
              'Confirm your current PIN, then choose a new one.',
              style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                color: Theme.of(context).colorScheme.onSurfaceVariant,
              ),
            ),
            const SizedBox(height: 24),
            Text('Current PIN', style: Theme.of(context).textTheme.labelLarge),
            const SizedBox(height: 8),
            _PinField(
              controller: _current,
              autofocus: true,
              onChanged: (_) => setState(() {}),
            ),
            const SizedBox(height: 16),
            Text('New PIN', style: Theme.of(context).textTheme.labelLarge),
            const SizedBox(height: 8),
            _PinField(
              controller: _next,
              onChanged: (_) => setState(() {}),
              onSubmitted: (_) => _submit(),
            ),
            const SizedBox(height: 24),
            FilledButton(
              onPressed: _isValid ? _submit : null,
              child: const Text('Change PIN'),
            ),
          ],
        ),
      ),
    );
  }
}

class _PinField extends StatelessWidget {
  const _PinField({
    required this.controller,
    this.onChanged,
    this.onSubmitted,
    this.autofocus = false,
  });

  final TextEditingController controller;
  final ValueChanged<String>? onChanged;
  final ValueChanged<String>? onSubmitted;
  final bool autofocus;

  @override
  Widget build(BuildContext context) {
    final int pinLength = BmoniEmbeddedSdk.pinLength;
    return TextField(
      controller: controller,
      autofocus: autofocus,
      obscureText: true,
      obscuringCharacter: '●',
      keyboardType: TextInputType.number,
      maxLength: pinLength,
      textAlign: TextAlign.center,
      onChanged: onChanged,
      onSubmitted: onSubmitted,
      inputFormatters: <TextInputFormatter>[
        FilteringTextInputFormatter.digitsOnly,
      ],
      style: const TextStyle(
        fontSize: 24,
        letterSpacing: 12,
        fontWeight: FontWeight.w600,
      ),
      decoration: InputDecoration(
        counterText: '',
        hintText: '$pinLength digits',
        hintStyle: TextStyle(
          fontSize: 14,
          letterSpacing: 0,
          color: Theme.of(context).colorScheme.onSurfaceVariant,
        ),
      ),
    );
  }
}

// --------------------------------------------------------------------
// Settings sheet — runtime SDK configuration
// --------------------------------------------------------------------

class _SettingsSheet extends StatefulWidget {
  const _SettingsSheet({required this.current});

  final BmoniEmbeddedSdkConfig current;

  static Future<BmoniEmbeddedSdkConfig?> show(
    BuildContext context, {
    required BmoniEmbeddedSdkConfig current,
  }) {
    return showModalBottomSheet<BmoniEmbeddedSdkConfig>(
      context: context,
      isScrollControlled: true,
      showDragHandle: true,
      builder: (BuildContext _) => _SettingsSheet(current: current),
    );
  }

  @override
  State<_SettingsSheet> createState() => _SettingsSheetState();
}

class _SettingsSheetState extends State<_SettingsSheet> {
  static const List<int> _pinLengthChoices = <int>[4, 6, 8];

  late int _pinLength = widget.current.pinLength;
  late bool _requirePin = widget.current.requirePin;

  void _save() {
    Navigator.of(context).pop(
      BmoniEmbeddedSdkConfig(pinLength: _pinLength, requirePin: _requirePin),
    );
  }

  @override
  Widget build(BuildContext context) {
    final TextTheme text = Theme.of(context).textTheme;
    final ColorScheme scheme = Theme.of(context).colorScheme;
    final EdgeInsets viewInsets = MediaQuery.viewInsetsOf(context);

    return Padding(
      padding: EdgeInsets.only(bottom: viewInsets.bottom),
      child: Padding(
        padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: <Widget>[
            Text('SDK settings', style: text.titleLarge),
            const SizedBox(height: 8),
            Text(
              'Reconfigures BmoniEmbeddedSdk.initialize(...). The PIN '
              'currently stored on the device stays put — but if you '
              'change the PIN length, you will need to remove and '
              're-create it for verification to succeed.',
              style: text.bodyMedium?.copyWith(color: scheme.onSurfaceVariant),
            ),
            const SizedBox(height: 24),
            Text('PIN length', style: text.labelLarge),
            const SizedBox(height: 8),
            SegmentedButton<int>(
              segments: <ButtonSegment<int>>[
                for (final int n in _pinLengthChoices)
                  ButtonSegment<int>(value: n, label: Text('$n')),
              ],
              selected: <int>{_pinLength},
              onSelectionChanged: (Set<int> selection) =>
                  setState(() => _pinLength = selection.first),
            ),
            const SizedBox(height: 24),
            SwitchListTile(
              contentPadding: EdgeInsets.zero,
              title: const Text('Require PIN before sign / delete'),
              subtitle: Text(
                _requirePin
                    ? 'signMessage / signTransactionHash / deleteWallet '
                          'verify the supplied PIN before invoking the '
                          'native plugin.'
                    : 'signMessage / signTransactionHash / deleteWallet '
                          'forward straight to the native plugin and ignore '
                          'any supplied PIN.',
              ),
              value: _requirePin,
              onChanged: (bool value) => setState(() => _requirePin = value),
            ),
            const SizedBox(height: 24),
            FilledButton(onPressed: _save, child: const Text('Apply')),
          ],
        ),
      ),
    );
  }
}

// --------------------------------------------------------------------
// Gated button — shows a tooltip with the missing prerequisite when
// disabled, so the user always knows what to do next instead of
// staring at an unexplained dead button.
// --------------------------------------------------------------------

class _GatedButton extends StatelessWidget {
  const _GatedButton({
    required this.enabled,
    required this.missingRequirement,
    required this.onPressed,
    required this.icon,
    required this.label,
  });

  final bool enabled;
  final String? missingRequirement;
  final VoidCallback onPressed;
  final IconData icon;
  final String label;

  @override
  Widget build(BuildContext context) {
    final FilledButton button = FilledButton.icon(
      icon: Icon(icon),
      label: Text(label),
      onPressed: enabled ? onPressed : null,
    );
    if (enabled || missingRequirement == null) return button;
    return Tooltip(
      message: missingRequirement!,
      triggerMode: TooltipTriggerMode.tap,
      showDuration: const Duration(seconds: 3),
      child: button,
    );
  }
}

/// Destructive variant of [_GatedButton] — renders an outlined button
/// in the theme's error color. Used for the explicit "Delete wallet"
/// action so it stands apart from the positive primary CTAs.
class _DestructiveGatedButton extends StatelessWidget {
  const _DestructiveGatedButton({
    required this.enabled,
    required this.missingRequirement,
    required this.onPressed,
    required this.icon,
    required this.label,
  });

  final bool enabled;
  final String? missingRequirement;
  final VoidCallback onPressed;
  final IconData icon;
  final String label;

  @override
  Widget build(BuildContext context) {
    final ColorScheme scheme = Theme.of(context).colorScheme;
    final OutlinedButton button = OutlinedButton.icon(
      icon: Icon(icon, color: scheme.error),
      label: Text(label, style: TextStyle(color: scheme.error)),
      style: OutlinedButton.styleFrom(
        side: BorderSide(color: scheme.error.withValues(alpha: 0.5)),
      ),
      onPressed: enabled ? onPressed : null,
    );
    if (enabled || missingRequirement == null) return button;
    return Tooltip(
      message: missingRequirement!,
      triggerMode: TooltipTriggerMode.tap,
      showDuration: const Duration(seconds: 3),
      child: button,
    );
  }
}
2
likes
160
points
74
downloads

Documentation

API reference

Publisher

verified publisherbkey.me

Weekly Downloads

Bmoni Embedded SDK for Flutter — Ethereum wallet provisioning and signing backed by Android Keystore / iOS Secure Enclave.

Repository (GitHub)
View/report issues

Topics

#ethereum #wallet #signer #web3 #secure-storage

License

Apache-2.0 (license)

Dependencies

crypto, flutter, flutter_secure_storage, plugin_platform_interface

More

Packages that depend on bmoni_embedded_sdk

Packages that implement bmoni_embedded_sdk