Skip to main content

android_otp_kit 0.1.1 copy "android_otp_kit: ^0.1.1" to clipboard
android_otp_kit: ^0.1.1 copied to clipboard

PlatformAndroid

Android OTP login in one package: Phone Number Hint, SMS Retriever (zero-tap), SMS User Consent fallback, app hash helper + CLI, and a self-filling OTP field.

example/lib/main.dart

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

void main() => runApp(
  MaterialApp(
    theme: ThemeData(colorSchemeSeed: Colors.deepPurple),
    home: const OtpDemo(),
  ),
);

enum FieldStyle { outlined, circle, underlined, filled, custom }

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

  @override
  State<OtpDemo> createState() => _OtpDemoState();
}

class _OtpDemoState extends State<OtpDemo> {
  final _phone = TextEditingController();
  final _otp = TextEditingController();
  List<String> _hashes = const [];
  SmsListenMode _mode = SmsListenMode.retriever;
  FieldStyle _style = FieldStyle.outlined;
  bool _obscure = false;
  bool _waitingForOtp = false;
  int _listenCount = 0;
  String _status = '';

  @override
  void initState() {
    super.initState();
    AndroidOtpKit.getAppSignatures().then((h) => setState(() => _hashes = h));
  }

  Future<void> _pickNumber() async {
    try {
      final number = await AndroidOtpKit.requestPhoneNumberHint();
      if (number != null) _phone.text = number;
    } on PlatformException catch (e) {
      setState(() => _status = 'Hint unavailable: ${e.message}');
    }
  }

  // This demo has no backend, so it can't send an SMS. It only starts
  // listening; send the test message from another phone (or to yourself).
  void _startListening() {
    _otp.clear();
    setState(() {
      _waitingForOtp = true;
      _listenCount++;
      _status = 'Listening for 5 minutes. Now send this SMS to this phone:';
    });
  }

  void _stopListening() => setState(() {
    _waitingForOtp = false;
    _status = '';
  });

  String get _testSms => _mode == SmsListenMode.retriever
      ? 'Your OTP is 482913\n\n${_hashes.isEmpty ? '<app hash>' : _hashes.first}'
      : 'Your OTP is 482913';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('android_otp_kit')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          TextField(
            controller: _phone,
            keyboardType: TextInputType.phone,
            decoration: InputDecoration(
              labelText: 'Phone number',
              suffixIcon: IconButton(
                icon: const Icon(Icons.sim_card_outlined),
                tooltip: 'Pick from SIM',
                onPressed: _pickNumber,
              ),
            ),
            onTap: () {
              if (_phone.text.isEmpty) _pickNumber();
            },
          ),
          const SizedBox(height: 16),
          SegmentedButton<SmsListenMode>(
            segments: const [
              ButtonSegment(
                value: SmsListenMode.retriever,
                label: Text('Retriever'),
              ),
              ButtonSegment(
                value: SmsListenMode.userConsent,
                label: Text('User consent'),
              ),
            ],
            selected: {_mode},
            onSelectionChanged: _waitingForOtp
                ? null
                : (s) => setState(() => _mode = s.first),
          ),
          const SizedBox(height: 16),
          _waitingForOtp
              ? OutlinedButton(
                  onPressed: _stopListening,
                  child: const Text('Stop listening'),
                )
              : FilledButton(
                  onPressed: _startListening,
                  child: const Text('Start listening for OTP'),
                ),
          const SizedBox(height: 24),
          const Text('Field style'),
          const SizedBox(height: 8),
          Wrap(
            spacing: 8,
            children: [
              for (final s in FieldStyle.values)
                ChoiceChip(
                  label: Text(s.name),
                  selected: _style == s,
                  onSelected: (_) => setState(() => _style = s),
                ),
              FilterChip(
                label: const Text('obscure'),
                selected: _obscure,
                onSelected: (v) => setState(() => _obscure = v),
              ),
            ],
          ),
          const SizedBox(height: 24),
          _buildOtpField(context),
          const SizedBox(height: 16),
          Text(_status),
          if (_waitingForOtp) ...[
            const SizedBox(height: 8),
            Card(
              child: ListTile(
                title: SelectableText(_testSms),
                trailing: IconButton(
                  icon: const Icon(Icons.copy),
                  tooltip: 'Copy',
                  onPressed: () =>
                      Clipboard.setData(ClipboardData(text: _testSms)),
                ),
              ),
            ),
          ],
          const Divider(height: 32),
          SelectableText('App hash (this build): ${_hashes.join(', ')}'),
        ],
      ),
    );
  }

  Widget _buildOtpField(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;
    return OtpTextField(
      // A new key restarts the SMS listener each time "Start listening" is tapped.
      key: ValueKey('$_listenCount-$_style'),
      controller: _otp,
      listenForSms: _waitingForOtp,
      mode: _mode,
      obscureText: _obscure,
      onSmsEvent: (e) => setState(() => _status = 'SMS event: $e'),
      validator: (code) => code == '482913' ? null : 'That code is not right',
      onCompleted: (code) => ScaffoldMessenger.of(
        context,
      ).showSnackBar(SnackBar(content: Text('Verifying $code…'))),
      defaultTheme: switch (_style) {
        FieldStyle.outlined => null, // package default
        FieldStyle.circle => OtpBoxTheme.circle(
          borderColor: scheme.outlineVariant,
          textStyle: const TextStyle(fontSize: 22, fontWeight: FontWeight.w600),
        ),
        FieldStyle.underlined => OtpBoxTheme.underlined(
          borderColor: scheme.outlineVariant,
          textStyle: const TextStyle(fontSize: 24, fontWeight: FontWeight.w600),
        ),
        FieldStyle.filled => OtpBoxTheme.filled(
          fillColor: scheme.surfaceContainerHighest,
          textStyle: const TextStyle(fontSize: 24, fontWeight: FontWeight.w600),
        ),
        FieldStyle.custom => null,
      },
      separatorBuilder: _style == FieldStyle.filled
          ? (i) =>
                i == 2 ? const Text('–', style: TextStyle(fontSize: 24)) : null
          : null,
      boxBuilder: _style == FieldStyle.custom
          ? (context, state) {
              // The active box gets a light fill, a ring and the cursor.
              final solid = state.isFilled && !state.isFocused;
              final text = Text(
                _obscure && state.isFilled ? '•' : state.value,
                style: TextStyle(
                  fontSize: 20,
                  fontWeight: FontWeight.bold,
                  color: solid ? scheme.onPrimary : scheme.primary,
                ),
              );
              return AnimatedContainer(
                duration: const Duration(milliseconds: 200),
                width: 44,
                height: 44,
                alignment: Alignment.center,
                decoration: BoxDecoration(
                  shape: BoxShape.circle,
                  color: state.hasError
                      ? scheme.errorContainer
                      : solid
                      ? scheme.primary
                      : state.isFocused
                      ? scheme.primaryContainer
                      : scheme.surfaceContainerHighest,
                  border: state.isFocused
                      ? Border.all(color: scheme.primary, width: 2)
                      : null,
                ),
                child: !state.isFocused
                    ? text
                    : Row(
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          if (state.isFilled) text,
                          // The package's cursor, so users see the active box.
                          OtpCursor(color: scheme.primary, height: 20),
                        ],
                      ),
              );
            }
          : null,
    );
  }
}
2
likes
160
points
494
downloads
screenshot

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Android OTP login in one package: Phone Number Hint, SMS Retriever (zero-tap), SMS User Consent fallback, app hash helper + CLI, and a self-filling OTP field.

Repository (GitHub)
View/report issues

Topics

#otp #sms #autofill #authentication #android

Funding

Consider supporting this project:

buymeacoffee.com

License

MIT (license)

Dependencies

crypto, flutter

More

Packages that depend on android_otp_kit

Packages that implement android_otp_kit