firebase_pnv 1.0.0+1 copy "firebase_pnv: ^1.0.0+1" to clipboard
firebase_pnv: ^1.0.0+1 copied to clipboard

Flutter bridge for Firebase Phone Number Verification (PNV) - carrier-based, SMS-free phone verification for Android via Credential Manager.

example/lib/main.dart

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

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

// ============================================================================
// Neobrutalist design tokens.
// ============================================================================

/// Shared neobrutalist design constants: stark black borders, harsh offset
/// shadows, bold flat colors, and no rounded softness.
class _NeoStyle {
  static const Color ink = Color(0xFF0A0A0A);
  static const Color bg = Color(0xFFF5F1E8);
  static const Color yellow = Color(0xFFFFD400);
  static const Color green = Color(0xFF00E676);
  static const Color pink = Color(0xFFFF5C8A);
  static const Color red = Color(0xFFFF3B30);
  static const Color card = Color(0xFFFFFFFF);

  static BoxDecoration block({
    Color color = card,
    double borderWidth = 3,
    Offset shadowOffset = const Offset(6, 6),
  }) {
    return BoxDecoration(
      color: color,
      border: Border.all(color: ink, width: borderWidth),
      boxShadow: [BoxShadow(color: ink, offset: shadowOffset, blurRadius: 0)],
    );
  }
}

// ============================================================================
// Verification flow status - state logic, kept separate from the UI below.
// ============================================================================

enum _VerificationStep { idle, loading, unsupported, success, error }

/// Holds the outcome of a verification attempt, decoupled from any widget.
class _VerificationState {
  const _VerificationState({
    this.step = _VerificationStep.idle,
    this.phoneNumber,
    this.token,
    this.errorMessage,
  });

  final _VerificationStep step;
  final String? phoneNumber;
  final String? token;
  final String? errorMessage;
}

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

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  final _firebasePnv = FirebasePnv();
  final _testTokenController = TextEditingController();
  _VerificationState _state = const _VerificationState();
  String? _testSessionMessage;

  bool get _isLoading => _state.step == _VerificationStep.loading;

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

  /// Dev-only helper: enables a Firebase PNV test session using a token
  /// generated from the Firebase console, so the flow can be exercised
  /// without a real SIM or billing account. Must be called once, before
  /// [checkSupport]/[getVerifiedPhoneNumber].
  Future<void> _enableTestSession() async {
    final token = _testTokenController.text.trim();
    if (token.isEmpty) return;

    try {
      await _firebasePnv.enableTestSession(token);
      if (!mounted) return;
      setState(() => _testSessionMessage = 'Test session enabled.');
    } on PlatformException catch (e) {
      if (!mounted) return;
      setState(() => _testSessionMessage = e.message ?? e.code);
    }
  }

  /// Runs the recommended PNV flow:
  /// 1. checkSupport() - cheap, consent-free capability check.
  /// 2. If supported, getVerifiedPhoneNumber() - shows the Credential
  ///    Manager consent sheet and verifies via the carrier.
  /// 3. If unsupported, surface a banner telling the caller to fall back to
  ///    SMS-based verification (e.g. firebase_auth).
  Future<void> _startVerification() async {
    setState(
      () => _state = const _VerificationState(step: _VerificationStep.loading),
    );

    final bool supported = await _firebasePnv.checkSupport();
    if (!mounted) return;

    if (!supported) {
      setState(
        () => _state = const _VerificationState(
          step: _VerificationStep.unsupported,
        ),
      );
      return;
    }

    try {
      final result = await _firebasePnv.getVerifiedPhoneNumber();
      if (!mounted) return;
      setState(
        () => _state = _VerificationState(
          step: _VerificationStep.success,
          phoneNumber: result?['phoneNumber']?.toString(),
          token: result?['token']?.toString(),
        ),
      );
    } on PlatformException catch (e) {
      if (!mounted) return;
      setState(
        () => _state = _VerificationState(
          step: _VerificationStep.error,
          errorMessage: e.message ?? e.code,
        ),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        scaffoldBackgroundColor: _NeoStyle.bg,
        fontFamily: 'monospace',
        useMaterial3: true,
      ),
      home: _HomeScreen(
        state: _state,
        isLoading: _isLoading,
        onVerifyPressed: _startVerification,
        testTokenController: _testTokenController,
        testSessionMessage: _testSessionMessage,
        onEnableTestSession: _enableTestSession,
      ),
    );
  }
}

// ============================================================================
// UI layer - purely presentational, driven entirely by _VerificationState.
// ============================================================================

class _HomeScreen extends StatelessWidget {
  const _HomeScreen({
    required this.state,
    required this.isLoading,
    required this.onVerifyPressed,
    required this.testTokenController,
    required this.testSessionMessage,
    required this.onEnableTestSession,
  });

  final _VerificationState state;
  final bool isLoading;
  final VoidCallback onVerifyPressed;
  final TextEditingController testTokenController;
  final String? testSessionMessage;
  final VoidCallback onEnableTestSession;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Stack(
          children: [
            SingleChildScrollView(
              padding: const EdgeInsets.all(20),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.stretch,
                children: [
                  const _HeaderBanner(),
                  const SizedBox(height: 20),
                  _TestSessionPanel(
                    controller: testTokenController,
                    message: testSessionMessage,
                    onEnable: onEnableTestSession,
                  ),
                  const SizedBox(height: 24),
                  _VerifyButton(onPressed: isLoading ? null : onVerifyPressed),
                  const SizedBox(height: 24),
                  if (state.step == _VerificationStep.unsupported)
                    const _AlertBanner(
                      color: _NeoStyle.red,
                      title: 'PNV NOT SUPPORTED',
                      message:
                          'This device/SIM cannot verify via carrier. Fall back to '
                          'SMS OTP verification (e.g. firebase_auth) instead.',
                    ),
                  if (state.step == _VerificationStep.error)
                    _AlertBanner(
                      color: _NeoStyle.red,
                      title: 'VERIFICATION FAILED',
                      message: state.errorMessage ?? 'Unknown error.',
                    ),
                  if (state.step == _VerificationStep.success)
                    _ResultBlock(
                      phoneNumber: state.phoneNumber ?? '-',
                      token: state.token ?? '-',
                    ),
                ],
              ),
            ),
            // Loading overlay: obscures the UI so the native Credential
            // Manager bottom sheet takes visual focus.
            if (isLoading) const _LoadingOverlay(),
          ],
        ),
      ),
    );
  }
}

class _HeaderBanner extends StatelessWidget {
  const _HeaderBanner();

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(20),
      decoration: _NeoStyle.block(color: _NeoStyle.yellow),
      child: const Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            'FIREBASE PNV',
            style: TextStyle(
              fontSize: 32,
              fontWeight: FontWeight.w900,
              color: _NeoStyle.ink,
              letterSpacing: 1,
            ),
          ),
          SizedBox(height: 8),
          Text(
            'NO SMS. NO OTP. JUST YOUR SIM.',
            style: TextStyle(
              fontSize: 14,
              fontWeight: FontWeight.bold,
              color: _NeoStyle.ink,
            ),
          ),
        ],
      ),
    );
  }
}

/// Dev-only panel to paste a Firebase console-generated test token and call
/// [FirebasePnv.enableTestSession], so the flow can be exercised without a
/// real SIM or billing account. See the README's "Testing without a real
/// SIM" section.
class _TestSessionPanel extends StatelessWidget {
  const _TestSessionPanel({
    required this.controller,
    required this.message,
    required this.onEnable,
  });

  final TextEditingController controller;
  final String? message;
  final VoidCallback onEnable;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(14),
      decoration: _NeoStyle.block(
        borderWidth: 2,
        shadowOffset: const Offset(3, 3),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const Text(
            'DEV: TEST SESSION TOKEN',
            style: TextStyle(
              fontSize: 11,
              fontWeight: FontWeight.w900,
              color: _NeoStyle.ink,
            ),
          ),
          const SizedBox(height: 8),
          Row(
            children: [
              Expanded(
                child: TextField(
                  controller: controller,
                  style: const TextStyle(fontSize: 12, color: _NeoStyle.ink),
                  decoration: const InputDecoration(
                    isDense: true,
                    border: OutlineInputBorder(),
                    hintText: 'Paste console token',
                  ),
                ),
              ),
              const SizedBox(width: 8),
              GestureDetector(
                onTap: onEnable,
                child: Container(
                  padding: const EdgeInsets.symmetric(
                    horizontal: 12,
                    vertical: 10,
                  ),
                  decoration: _NeoStyle.block(
                    color: _NeoStyle.yellow,
                    borderWidth: 2,
                    shadowOffset: const Offset(2, 2),
                  ),
                  child: const Text(
                    'ENABLE',
                    style: TextStyle(
                      fontSize: 11,
                      fontWeight: FontWeight.w900,
                      color: _NeoStyle.ink,
                    ),
                  ),
                ),
              ),
            ],
          ),
          if (message != null) ...[
            const SizedBox(height: 6),
            Text(
              message!,
              style: const TextStyle(fontSize: 11, color: _NeoStyle.ink),
            ),
          ],
        ],
      ),
    );
  }
}

class _VerifyButton extends StatelessWidget {
  const _VerifyButton({required this.onPressed});

  final VoidCallback? onPressed;

  @override
  Widget build(BuildContext context) {
    final bool enabled = onPressed != null;
    return GestureDetector(
      onTap: onPressed,
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 120),
        padding: const EdgeInsets.symmetric(vertical: 22),
        decoration: _NeoStyle.block(
          color: enabled ? _NeoStyle.green : _NeoStyle.card,
          shadowOffset: enabled ? const Offset(6, 6) : const Offset(2, 2),
        ),
        alignment: Alignment.center,
        child: Text(
          enabled ? 'VERIFY PHONE' : 'WORKING...',
          style: const TextStyle(
            fontSize: 20,
            fontWeight: FontWeight.w900,
            color: _NeoStyle.ink,
            letterSpacing: 1.5,
          ),
        ),
      ),
    );
  }
}

class _AlertBanner extends StatelessWidget {
  const _AlertBanner({
    required this.color,
    required this.title,
    required this.message,
  });

  final Color color;
  final String title;
  final String message;

  @override
  Widget build(BuildContext context) {
    return Container(
      margin: const EdgeInsets.only(bottom: 16),
      padding: const EdgeInsets.all(16),
      decoration: _NeoStyle.block(color: color),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            title,
            style: const TextStyle(
              fontSize: 16,
              fontWeight: FontWeight.w900,
              color: _NeoStyle.ink,
            ),
          ),
          const SizedBox(height: 6),
          Text(
            message,
            style: const TextStyle(fontSize: 13, color: _NeoStyle.ink),
          ),
        ],
      ),
    );
  }
}

/// Displays the verified phone number and PNV token in a copyable block,
/// so it can be quickly shared with beta testers for backend debugging.
class _ResultBlock extends StatelessWidget {
  const _ResultBlock({required this.phoneNumber, required this.token});

  final String phoneNumber;
  final String token;

  void _copy(BuildContext context, String label, String value) {
    Clipboard.setData(ClipboardData(text: value));
    ScaffoldMessenger.of(
      context,
    ).showSnackBar(SnackBar(content: Text('$label copied to clipboard')));
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(16),
      decoration: _NeoStyle.block(color: _NeoStyle.pink),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const Text(
            'VERIFIED ✓',
            style: TextStyle(
              fontSize: 18,
              fontWeight: FontWeight.w900,
              color: _NeoStyle.ink,
            ),
          ),
          const SizedBox(height: 16),
          _CopyableField(
            label: 'PHONE NUMBER',
            value: phoneNumber,
            onCopy: () => _copy(context, 'Phone number', phoneNumber),
          ),
          const SizedBox(height: 12),
          _CopyableField(
            label: 'TOKEN',
            value: token,
            onCopy: () => _copy(context, 'Token', token),
          ),
        ],
      ),
    );
  }
}

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

  final String label;
  final String value;
  final VoidCallback onCopy;

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          label,
          style: const TextStyle(
            fontSize: 12,
            fontWeight: FontWeight.w900,
            color: _NeoStyle.ink,
          ),
        ),
        const SizedBox(height: 6),
        Container(
          padding: const EdgeInsets.all(10),
          decoration: _NeoStyle.block(
            color: _NeoStyle.card,
            borderWidth: 2,
            shadowOffset: const Offset(3, 3),
          ),
          child: Row(
            children: [
              Expanded(
                child: Text(
                  value,
                  style: const TextStyle(fontSize: 13, color: _NeoStyle.ink),
                  overflow: TextOverflow.ellipsis,
                ),
              ),
              IconButton(
                icon: const Icon(Icons.copy, size: 18, color: _NeoStyle.ink),
                onPressed: onCopy,
              ),
            ],
          ),
        ),
      ],
    );
  }
}

/// A full-screen glass-like overlay shown while a PNV call is in flight, so
/// the native Credential Manager bottom sheet has undivided visual focus.
class _LoadingOverlay extends StatelessWidget {
  const _LoadingOverlay();

  @override
  Widget build(BuildContext context) {
    return Positioned.fill(
      child: Container(
        color: _NeoStyle.ink.withValues(alpha: 0.85),
        alignment: Alignment.center,
        child: Container(
          padding: const EdgeInsets.all(24),
          decoration: _NeoStyle.block(color: _NeoStyle.yellow),
          child: const Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              SizedBox(
                width: 32,
                height: 32,
                child: CircularProgressIndicator(
                  color: _NeoStyle.ink,
                  strokeWidth: 4,
                ),
              ),
              SizedBox(height: 16),
              Text(
                'AWAITING CARRIER\nVERIFICATION...',
                textAlign: TextAlign.center,
                style: TextStyle(
                  fontWeight: FontWeight.w900,
                  color: _NeoStyle.ink,
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}
0
likes
155
points
192
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Flutter bridge for Firebase Phone Number Verification (PNV) - carrier-based, SMS-free phone verification for Android via Credential Manager.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

flutter, flutter_web_plugins, plugin_platform_interface

More

Packages that depend on firebase_pnv

Packages that implement firebase_pnv