my_otp_way 0.1.0 copy "my_otp_way: ^0.1.0" to clipboard
my_otp_way: ^0.1.0 copied to clipboard

Send and verify OTP codes through your own backend, without shipping an API key inside your app.

example/lib/main.dart

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

/// Point this at **your own backend** — the Laravel app that published
/// `MyOtpWay::routes()` in `routes/api.php` — and replace it before running.
///
/// It is never the MY-OTP-Way platform itself: this app holds no API key, and
/// that is the entire reason the proxy exists.
const String backendBaseUrl = 'https://api.your-backend.example';

void main() => runApp(const ExampleApp());

/// Wraps the single screen this example has.
class ExampleApp extends StatelessWidget {
  /// Creates the app.
  const ExampleApp({super.key});

  @override
  Widget build(BuildContext context) => const MaterialApp(
        title: 'MY-OTP-Way example',
        home: OtpScreen(),
      );
}

/// Runs one phone number through send → verify by driving an [OtpSession]
/// directly.
///
/// [MyOtpBuilder] would do this wiring in five lines. This screen does it by
/// hand on purpose, so every part of the lower-level API — the status, both
/// countdowns, the resend gate, the attempts counter and the typed error — is
/// visible in one place.
class OtpScreen extends StatefulWidget {
  /// Creates the screen.
  const OtpScreen({super.key});

  @override
  State<OtpScreen> createState() => _OtpScreenState();
}

class _OtpScreenState extends State<OtpScreen> {
  final MyOtpWay _client = MyOtpWay(baseUrl: backendBaseUrl);
  final TextEditingController _phone =
      TextEditingController(text: '+9647701234567');
  final TextEditingController _code = TextEditingController();

  OtpSession? _session;

  @override
  void dispose() {
    _session?.dispose();
    _client.close();
    _phone.dispose();
    _code.dispose();
    super.dispose();
  }

  /// Starts a fresh session for the number in the phone field.
  ///
  /// A session is bound to one number for its whole life, so correcting a typo
  /// means disposing the old session and creating another — the same reason
  /// [MyOtpBuilder] wants a `ValueKey` on the *submitted* number. Called from
  /// the Send button, never from the field's `onChanged`: each call is a paid
  /// message, and a brand-new session has no cooldown to refuse it.
  void _start() {
    _session?.dispose();
    final OtpSession session =
        OtpSession(client: _client, phone: _phone.text.trim());
    setState(() => _session = session);
    session.send();
  }

  @override
  Widget build(BuildContext context) {
    final OtpSession? session = _session;

    return Scaffold(
      appBar: AppBar(title: const Text('MY-OTP-Way')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: <Widget>[
            TextField(
              controller: _phone,
              keyboardType: TextInputType.phone,
              decoration: const InputDecoration(
                labelText: 'Phone number',
                helperText: 'In E.164 form, e.g. +9647701234567',
              ),
            ),
            const SizedBox(height: 12),
            FilledButton(
              onPressed: _start,
              child: Text(session == null ? 'Send code' : 'Start over'),
            ),
            const Divider(height: 32),
            if (session == null)
              const Text('No session yet. Send a code to begin.')
            else
              Expanded(
                // OtpSession is a ChangeNotifier: it notifies on every state
                // change AND once a second while a countdown is running, so
                // the countdowns below tick without any timer of our own.
                child: ListenableBuilder(
                  listenable: session,
                  builder: (BuildContext context, Widget? child) =>
                      _sessionView(session),
                ),
              ),
          ],
        ),
      ),
    );
  }

  Widget _sessionView(OtpSession session) {
    final MyOtpException? error = session.error;
    final int? attemptsRemaining = session.attemptsRemaining;

    return ListView(
      children: <Widget>[
        Text('Status: ${session.status.name}'),
        Text('Request id: ${session.requestId ?? '—'}'),
        Text('Code expires in: ${_countdown(session.expiresIn)}'),
        Text(
          session.canResend
              ? 'Resend: available now'
              : 'Resend: in ${_countdown(session.resendAvailableIn)}',
        ),
        if (attemptsRemaining != null)
          Text('Attempts remaining: $attemptsRemaining'),
        if (error != null)
          Padding(
            padding: const EdgeInsets.only(top: 8),
            child: Text(
              describeOtpError(error),
              style: TextStyle(color: Theme.of(context).colorScheme.error),
            ),
          ),
        const SizedBox(height: 16),
        TextField(
          controller: _code,
          keyboardType: TextInputType.number,
          decoration: const InputDecoration(labelText: 'Code'),
        ),
        const SizedBox(height: 12),
        Row(
          children: <Widget>[
            Expanded(
              child: FilledButton(
                onPressed: session.status == OtpSessionStatus.awaiting
                    ? () => session.verify(_code.text.trim())
                    : null,
                child: const Text('Verify'),
              ),
            ),
            const SizedBox(width: 12),
            Expanded(
              child: OutlinedButton(
                // canResend is the whole point of the cooldown: a disabled
                // button is what stops a user draining the developer's
                // balance by holding it down.
                onPressed: session.canResend ? session.resend : null,
                child: const Text('Resend'),
              ),
            ),
          ],
        ),
        if (session.status == OtpSessionStatus.verified)
          const Padding(
            padding: EdgeInsets.only(top: 16),
            child: Text('Verified.'),
          ),
      ],
    );
  }

  String _countdown(Duration? left) =>
      left == null ? '—' : '${left.inSeconds}s';
}

/// Turns a [MyOtpException] into something worth showing a user.
///
/// The `switch` has no `default` arm: [MyOtpException] is `sealed`, so the
/// compiler points at this function if the package ever adds a variant.
String describeOtpError(MyOtpException error) => switch (error) {
      Forbidden() => 'Your backend refused this request.',
      InvalidPhone() => 'That phone number does not look right.',
      CountryNotAllowed() => 'That country is not enabled on this backend.',
      NothingToResend() => 'Send a code before asking for another one.',
      // availableIn is nullable: null means the server did not say how long,
      // which is not the same as "resend now".
      ResendTooSoon(:final Duration? availableIn) => availableIn == null
          ? 'Too soon to resend. Try again shortly.'
          : 'Too soon to resend. Try again in ${availableIn.inSeconds}s.',
      InvalidRequest() => 'The request was malformed.',
      RateLimited(:final Duration? retryAfter) => retryAfter == null
          ? 'Too many requests. Try again later.'
          : 'Too many requests. Try again in ${retryAfter.inSeconds}s.',
      ServiceUnavailable() => 'The service is unavailable. Try again later.',
      WrongCode(:final int? attemptsRemaining) => attemptsRemaining == null
          ? 'That code is wrong.'
          : 'That code is wrong — $attemptsRemaining attempts left.',
      CodeExpired() => 'That code expired. Ask for a new one.',
      AlreadyUsed() => 'That code was already used.',
      NotFound() => 'The server no longer knows about that request.',
      TooManyAttempts() => 'Too many wrong codes. Start over.',
      NetworkFailure() => 'Could not reach your backend.',
    };
0
likes
160
points
18
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Send and verify OTP codes through your own backend, without shipping an API key inside your app.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

clock, flutter, http

More

Packages that depend on my_otp_way