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
5
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