my_otp_way 0.1.0
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.
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.',
};